style_capsule 2.0.1 → 2.0.3
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 +20 -1
- data/README.md +44 -29
- data/SECURITY.md +11 -14
- data/docs/changelogs/20260904180000_audit-fixes.md +33 -0
- data/docs/changelogs/20260925094000_head-injection-bufferable-chunked.md +25 -0
- data/docs/changelogs/20260928193000_sync-appraisal-locks-after-version-bump.md +19 -0
- data/docs/dependencies/20260904174500_json-cve-2026-71847.md +37 -0
- data/docs/issues/20260904174500_engineering-and-dependency-audit.md +51 -0
- data/docs/issues/20260917123253_ci-drop-automatic-dependency-audit.md +28 -0
- data/docs/issues/20261103150931_engineering-prose-release-audit.md +40 -0
- data/docs/non_rails_support.md +2 -2
- data/docs/reviews/20261028_engineering-audit-release-preparation.md +25 -0
- data/docs/reviews/20261103150931_engineering-prose-release-audit.md +42 -0
- data/lib/style_capsule/component.rb +1 -1
- data/lib/style_capsule/component_builder.rb +1 -1
- data/lib/style_capsule/component_styles_support.rb +10 -0
- data/lib/style_capsule/css_file_writer.rb +5 -4
- data/lib/style_capsule/css_processor.rb +220 -83
- data/lib/style_capsule/head_injection_middleware.rb +22 -7
- data/lib/style_capsule/stylesheet_registry.rb +93 -62
- data/lib/style_capsule/version.rb +1 -1
- data/lib/style_capsule/view_component.rb +1 -1
- data/sig/style_capsule.rbs +6 -1
- metadata +22 -72
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "digest/sha1"
|
|
5
|
+
require "tmpdir"
|
|
5
6
|
require_relative "instrumentation"
|
|
6
7
|
|
|
7
8
|
module StyleCapsule
|
|
@@ -12,7 +13,7 @@ module StyleCapsule
|
|
|
12
13
|
# via Rails asset pipeline.
|
|
13
14
|
#
|
|
14
15
|
# In production environments where the app directory is read-only (e.g., Docker containers),
|
|
15
|
-
# this class automatically falls back to writing files
|
|
16
|
+
# this class automatically falls back to writing files under Dir.tmpdir when the
|
|
16
17
|
# default location is not writable. When using the fallback directory, write_css returns
|
|
17
18
|
# nil, causing StylesheetRegistry to fall back to inline CSS (keeping the UI functional).
|
|
18
19
|
#
|
|
@@ -32,7 +33,7 @@ module StyleCapsule
|
|
|
32
33
|
# StyleCapsule::CssFileWriter.configure(
|
|
33
34
|
# output_dir: Rails.root.join(StyleCapsule::CssFileWriter::DEFAULT_OUTPUT_DIR),
|
|
34
35
|
# filename_pattern: ->(component_class, capsule_id) { "capsule-#{capsule_id}.css" },
|
|
35
|
-
# fallback_dir:
|
|
36
|
+
# fallback_dir: StyleCapsule::CssFileWriter::FALLBACK_OUTPUT_DIR
|
|
36
37
|
# )
|
|
37
38
|
#
|
|
38
39
|
# @example Usage
|
|
@@ -74,7 +75,7 @@ module StyleCapsule
|
|
|
74
75
|
# Default output directory for CSS files (relative to Rails root)
|
|
75
76
|
DEFAULT_OUTPUT_DIR = "app/assets/builds/capsules"
|
|
76
77
|
# Fallback directory for when default location is read-only (absolute path)
|
|
77
|
-
FALLBACK_OUTPUT_DIR = "
|
|
78
|
+
FALLBACK_OUTPUT_DIR = File.join(Dir.tmpdir, "style_capsule-#{Process.uid}")
|
|
78
79
|
# Positive cache for resolved asset-relative paths (avoids repeated File.exist? on hot path)
|
|
79
80
|
FILE_PATH_CACHE_MAX = 512
|
|
80
81
|
|
|
@@ -90,7 +91,7 @@ module StyleCapsule
|
|
|
90
91
|
# Default: `"capsule-#{capsule_id}.css"` (capsule_id is unique and deterministic)
|
|
91
92
|
# @param enabled [Boolean] Whether file writing is enabled (default: true)
|
|
92
93
|
# @param fallback_dir [String, Pathname, nil] Fallback directory when default location is read-only
|
|
93
|
-
# Default: `StyleCapsule::CssFileWriter::FALLBACK_OUTPUT_DIR`
|
|
94
|
+
# Default: `StyleCapsule::CssFileWriter::FALLBACK_OUTPUT_DIR`
|
|
94
95
|
# @example
|
|
95
96
|
# StyleCapsule::CssFileWriter.configure(
|
|
96
97
|
# output_dir: Rails.root.join(StyleCapsule::CssFileWriter::DEFAULT_OUTPUT_DIR),
|
|
@@ -7,15 +7,25 @@ module StyleCapsule
|
|
|
7
7
|
#
|
|
8
8
|
# Supports two scoping strategies:
|
|
9
9
|
# 1. Selector patching (default): Adds [data-capsule="..."] prefix to each selector
|
|
10
|
-
# -
|
|
10
|
+
# - Wider browser support (all modern browsers)
|
|
11
11
|
# - Requires CSS parsing and transformation
|
|
12
|
-
# 2. CSS nesting (optional): Wraps
|
|
13
|
-
# -
|
|
12
|
+
# 2. CSS nesting (optional): Wraps style rules in [data-capsule="..."] { ... }
|
|
13
|
+
# - Avoids per-selector rewrite
|
|
14
14
|
# - Requires CSS nesting support (Chrome 112+, Firefox 117+, Safari 16.5+)
|
|
15
|
+
# - Hoists non-nestable at-rules (@keyframes, @font-face, @import, ...) to top level
|
|
16
|
+
# rubocop:disable Metrics/ModuleLength -- processor owns patching, nesting hoist, and validation
|
|
15
17
|
module CssProcessor
|
|
16
18
|
# Maximum CSS content size (1MB) to prevent DoS attacks
|
|
17
19
|
MAX_CSS_SIZE = 1_000_000
|
|
18
20
|
|
|
21
|
+
# At-rules whose inner selectors must not receive attribute scoping
|
|
22
|
+
NON_SCOPING_AT_RULES = %w[keyframes font-face property].freeze
|
|
23
|
+
|
|
24
|
+
# At-rules that must stay at the top level under CSS nesting
|
|
25
|
+
HOISTABLE_AT_RULES = %w[
|
|
26
|
+
keyframes font-face import charset namespace property
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
19
29
|
# Rewrite CSS selectors to include attribute-based scoping
|
|
20
30
|
#
|
|
21
31
|
# Transforms:
|
|
@@ -26,98 +36,40 @@ module StyleCapsule
|
|
|
26
36
|
# [data-capsule="a1b2c3d4"] .section { color: red; }
|
|
27
37
|
# [data-capsule="a1b2c3d4"] .heading:hover { opacity: 0.8; }
|
|
28
38
|
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
# Simple approach:
|
|
33
|
-
# - Strips CSS comments first to avoid interference
|
|
34
|
-
# - Finds selectors before opening braces and prefixes them
|
|
35
|
-
# - Handles @media queries (preserves them, scopes inner selectors)
|
|
36
|
-
# - Handles :host and :host-context (component-scoped selectors)
|
|
39
|
+
# Uses attribute selectors instead of renaming classes, so styles apply only
|
|
40
|
+
# within scoped components. Leaves @keyframes / @font-face / @property steps alone.
|
|
37
41
|
#
|
|
38
42
|
# @param css_string [String] Original CSS content
|
|
39
43
|
# @param capsule_id [String] The capsule ID to use in attribute selector
|
|
40
44
|
# @param component_class [Class, String, nil] Optional component class for instrumentation
|
|
41
45
|
# @return [String] CSS with scoped selectors
|
|
42
46
|
# @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
|
|
44
47
|
def self.scope_selectors(css_string, capsule_id, component_class: nil)
|
|
45
48
|
return css_string if css_string.nil? || css_string.strip.empty?
|
|
46
49
|
|
|
47
|
-
# Validate CSS size to prevent DoS attacks
|
|
48
50
|
if css_string.bytesize > MAX_CSS_SIZE
|
|
49
51
|
raise ArgumentError, "CSS content exceeds maximum size of #{MAX_CSS_SIZE} bytes (got #{css_string.bytesize} bytes)"
|
|
50
52
|
end
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
reject_style_element_breakout!(css_string)
|
|
53
55
|
validate_capsule_id!(capsule_id)
|
|
54
56
|
|
|
55
|
-
# Instrument CSS processing with timing and size metrics
|
|
56
57
|
Instrumentation.instrument_css_processing(
|
|
57
58
|
strategy: :selector_patching,
|
|
58
59
|
component_class: component_class || "Unknown",
|
|
59
60
|
capsule_id: capsule_id,
|
|
60
61
|
css_content: css_string
|
|
61
62
|
) do
|
|
62
|
-
css = css_string.dup
|
|
63
63
|
capsule_attr = %([data-capsule="#{capsule_id}"])
|
|
64
|
-
|
|
65
|
-
# Strip CSS comments so they do not interfere with selector matching.
|
|
66
|
-
# Comments are removed from the output (typical for production CSS).
|
|
67
|
-
css_without_comments = strip_comments(css)
|
|
68
|
-
|
|
69
|
-
# Process CSS rule by rule
|
|
70
|
-
# Match: selector(s) { ... }
|
|
71
|
-
# Pattern: (start or closing brace) + (whitespace) + (selector text) + (opening brace)
|
|
72
|
-
# Note: Uses non-greedy quantifier ([^{}@]+?) to minimize backtracking
|
|
73
|
-
# MAX_CSS_SIZE limit (1MB) mitigates ReDoS risk from malicious input
|
|
74
|
-
css_without_comments.gsub!(/(^|\})(\s*)([^{}@]+?)(\{)/m) do |_|
|
|
75
|
-
prefix = Regexp.last_match(1) # Previous closing brace or start
|
|
76
|
-
whitespace = Regexp.last_match(2) # Whitespace between rules
|
|
77
|
-
selectors_raw = Regexp.last_match(3) # The selector group
|
|
78
|
-
selectors = selectors_raw.strip # Stripped for processing
|
|
79
|
-
opening_brace = Regexp.last_match(4) # The opening brace
|
|
80
|
-
|
|
81
|
-
# Skip at-rules (@media, @keyframes, etc.) - they should not be scoped at top level
|
|
82
|
-
next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors.start_with?("@")
|
|
83
|
-
|
|
84
|
-
# Skip if already scoped (avoid double-scoping)
|
|
85
|
-
next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors_raw.include?("[data-capsule=")
|
|
86
|
-
|
|
87
|
-
# Skip empty selectors
|
|
88
|
-
next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors.empty?
|
|
89
|
-
|
|
90
|
-
# Split selectors by comma and scope each one
|
|
91
|
-
scoped_selectors = selectors.split(",").map do |selector|
|
|
92
|
-
selector = selector.strip
|
|
93
|
-
next selector if selector.empty?
|
|
94
|
-
|
|
95
|
-
# Handle special component-scoped selectors (:host, :host-context)
|
|
96
|
-
if selector.start_with?(":host")
|
|
97
|
-
selector = selector
|
|
98
|
-
.gsub(/^:host-context\(([^)]+)\)/, "#{capsule_attr} \\1")
|
|
99
|
-
.gsub(/^:host\(([^)]+)\)/, "#{capsule_attr}\\1")
|
|
100
|
-
.gsub(/^:host\b/, capsule_attr)
|
|
101
|
-
selector
|
|
102
|
-
else
|
|
103
|
-
# Add capsule attribute with space before selector for descendant matching
|
|
104
|
-
# This ensures styles apply to elements inside the scoped wrapper
|
|
105
|
-
"#{capsule_attr} #{selector}"
|
|
106
|
-
end
|
|
107
|
-
end.compact.join(", ")
|
|
108
|
-
|
|
109
|
-
"#{prefix}#{whitespace}#{scoped_selectors}#{opening_brace}"
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
css_without_comments
|
|
64
|
+
patch_selectors(strip_comments(css_string.dup), capsule_attr)
|
|
113
65
|
end
|
|
114
66
|
end
|
|
115
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
116
67
|
|
|
117
|
-
# Scope CSS using CSS nesting (wraps
|
|
68
|
+
# Scope CSS using CSS nesting (wraps nestable rules in [data-capsule] { ... })
|
|
118
69
|
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
70
|
+
# Avoids per-selector rewrite. Requires CSS nesting support in browsers
|
|
71
|
+
# (Chrome 112+, Firefox 117+, Safari 16.5+). Non-nestable at-rules are written
|
|
72
|
+
# before the wrapper so @keyframes and @font-face stay valid.
|
|
121
73
|
#
|
|
122
74
|
# Transforms:
|
|
123
75
|
# .section { color: red; }
|
|
@@ -132,43 +84,60 @@ module StyleCapsule
|
|
|
132
84
|
# @param css_string [String] Original CSS content
|
|
133
85
|
# @param capsule_id [String] The capsule ID to use in attribute selector
|
|
134
86
|
# @param component_class [Class, String, nil] Optional component class for instrumentation
|
|
135
|
-
# @return [String] CSS
|
|
87
|
+
# @return [String] CSS with nesting wrapper and hoisted at-rules
|
|
136
88
|
# @raise [ArgumentError] If CSS content exceeds maximum size or capsule_id is invalid
|
|
89
|
+
# rubocop:disable Metrics/AbcSize -- size check, breakout, instrument, then hoist and wrap
|
|
137
90
|
def self.scope_with_nesting(css_string, capsule_id, component_class: nil)
|
|
138
91
|
return css_string if css_string.nil? || css_string.strip.empty?
|
|
139
92
|
|
|
140
|
-
# Validate CSS size to prevent DoS attacks
|
|
141
93
|
if css_string.bytesize > MAX_CSS_SIZE
|
|
142
94
|
raise ArgumentError, "CSS content exceeds maximum size of #{MAX_CSS_SIZE} bytes (got #{css_string.bytesize} bytes)"
|
|
143
95
|
end
|
|
144
96
|
|
|
145
|
-
|
|
97
|
+
reject_style_element_breakout!(css_string)
|
|
146
98
|
validate_capsule_id!(capsule_id)
|
|
147
99
|
|
|
148
|
-
# Instrument CSS processing with timing and size metrics
|
|
149
100
|
Instrumentation.instrument_css_processing(
|
|
150
101
|
strategy: :nesting,
|
|
151
102
|
component_class: component_class || "Unknown",
|
|
152
103
|
capsule_id: capsule_id,
|
|
153
104
|
css_content: css_string
|
|
154
105
|
) do
|
|
155
|
-
# Simply wrap the entire CSS in the capsule attribute selector
|
|
156
|
-
# No parsing or transformation needed - much more performant
|
|
157
106
|
capsule_attr = %([data-capsule="#{capsule_id}"])
|
|
158
|
-
|
|
107
|
+
hoisted, nestable = hoist_top_level_at_rules(css_string)
|
|
108
|
+
parts = []
|
|
109
|
+
parts << hoisted unless hoisted.empty?
|
|
110
|
+
parts << "#{capsule_attr} {\n#{nestable}\n}" unless nestable.strip.empty?
|
|
111
|
+
parts.join("\n")
|
|
159
112
|
end
|
|
160
113
|
end
|
|
114
|
+
# rubocop:enable Metrics/AbcSize
|
|
161
115
|
|
|
162
116
|
# Strip CSS comments (/* ... */) from the string
|
|
163
117
|
#
|
|
164
118
|
# @param css [String] CSS content with comments
|
|
165
119
|
# @return [String] CSS content without comments
|
|
166
120
|
def self.strip_comments(css)
|
|
167
|
-
# Remove /* ... */ comments (including multi-line)
|
|
168
|
-
# Use non-greedy match to handle multiple comments
|
|
169
121
|
css.gsub(/\/\*.*?\*\//m, "")
|
|
170
122
|
end
|
|
171
123
|
|
|
124
|
+
# Reject CSS that would close an enclosing HTML style element
|
|
125
|
+
#
|
|
126
|
+
# HTML parses style-element content until the first case-insensitive closer,
|
|
127
|
+
# including sequences inside CSS comments or string literals.
|
|
128
|
+
#
|
|
129
|
+
# @param css_string [String, nil] CSS that will be placed inside a style tag
|
|
130
|
+
# @raise [ArgumentError] If the string contains a style element closer
|
|
131
|
+
def self.reject_style_element_breakout!(css_string)
|
|
132
|
+
# Core path must not call ActiveSupport String#blank?
|
|
133
|
+
# rubocop:disable Rails/Blank -- standalone hosts may lack ActiveSupport
|
|
134
|
+
return if css_string.nil? || css_string.empty?
|
|
135
|
+
# rubocop:enable Rails/Blank
|
|
136
|
+
return unless css_string.match?(%r{</style}i)
|
|
137
|
+
|
|
138
|
+
raise ArgumentError, "CSS content must not contain a style element closer"
|
|
139
|
+
end
|
|
140
|
+
|
|
172
141
|
# Validate capsule ID to prevent injection attacks
|
|
173
142
|
#
|
|
174
143
|
# @param capsule_id [String] Capsule ID to validate
|
|
@@ -178,23 +147,191 @@ module StyleCapsule
|
|
|
178
147
|
raise ArgumentError, "capsule_id must be a String (got #{capsule_id.class})"
|
|
179
148
|
end
|
|
180
149
|
|
|
181
|
-
# Must not be empty (check first for clearer error message)
|
|
182
150
|
if capsule_id.empty?
|
|
183
151
|
raise ArgumentError, "capsule_id cannot be empty"
|
|
184
152
|
end
|
|
185
153
|
|
|
186
|
-
# Capsule ID should only contain alphanumeric characters, hyphens, and underscores
|
|
187
|
-
# This prevents injection into HTML attributes
|
|
188
154
|
unless capsule_id.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
189
155
|
raise ArgumentError, "Invalid capsule_id: must contain only alphanumeric characters, hyphens, and underscores (got: #{capsule_id.inspect})"
|
|
190
156
|
end
|
|
191
157
|
|
|
192
|
-
# Reasonable length limit
|
|
193
158
|
if capsule_id.length > 100
|
|
194
159
|
raise ArgumentError, "Invalid capsule_id: too long (max 100 characters, got #{capsule_id.length})"
|
|
195
160
|
end
|
|
196
161
|
end
|
|
197
162
|
|
|
198
|
-
|
|
163
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- walks braces and at-rules in one pass
|
|
164
|
+
def self.patch_selectors(css, capsule_attr)
|
|
165
|
+
output = +""
|
|
166
|
+
index = 0
|
|
167
|
+
length = css.length
|
|
168
|
+
brace_depth = 0
|
|
169
|
+
suppress_below_depth = nil
|
|
170
|
+
|
|
171
|
+
while index < length
|
|
172
|
+
while index < length && css[index].match?(/\s/)
|
|
173
|
+
output << css[index]
|
|
174
|
+
index += 1
|
|
175
|
+
end
|
|
176
|
+
break if index >= length
|
|
177
|
+
|
|
178
|
+
if css[index] == "}"
|
|
179
|
+
output << "}"
|
|
180
|
+
index += 1
|
|
181
|
+
brace_depth -= 1
|
|
182
|
+
if !suppress_below_depth.nil? && brace_depth <= suppress_below_depth
|
|
183
|
+
suppress_below_depth = nil
|
|
184
|
+
end
|
|
185
|
+
next
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
selector_start = index
|
|
189
|
+
while index < length && css[index] != "{" && css[index] != "}"
|
|
190
|
+
index += 1
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
selectors_raw = css[selector_start...index]
|
|
194
|
+
if index >= length || css[index] == "}"
|
|
195
|
+
output << selectors_raw
|
|
196
|
+
next
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
selectors = selectors_raw.strip
|
|
200
|
+
at_name = selectors[/\A@([a-zA-Z-]+)/, 1]&.downcase
|
|
201
|
+
entering_non_scoping = at_name && NON_SCOPING_AT_RULES.include?(at_name) && suppress_below_depth.nil?
|
|
202
|
+
|
|
203
|
+
skip_scoping = !suppress_below_depth.nil? ||
|
|
204
|
+
selectors.start_with?("@") ||
|
|
205
|
+
selectors_raw.include?("[data-capsule=") ||
|
|
206
|
+
selectors.empty?
|
|
207
|
+
|
|
208
|
+
if skip_scoping
|
|
209
|
+
output << selectors_raw
|
|
210
|
+
if entering_non_scoping
|
|
211
|
+
suppress_below_depth = brace_depth
|
|
212
|
+
end
|
|
213
|
+
else
|
|
214
|
+
scoped_selectors = selectors.split(",").map do |selector|
|
|
215
|
+
selector = selector.strip
|
|
216
|
+
next selector if selector.empty?
|
|
217
|
+
|
|
218
|
+
if selector.start_with?(":host")
|
|
219
|
+
selector
|
|
220
|
+
.gsub(/^:host-context\(([^)]+)\)/, "#{capsule_attr} \\1")
|
|
221
|
+
.gsub(/^:host\(([^)]+)\)/, "#{capsule_attr}\\1")
|
|
222
|
+
.gsub(/^:host\b/, capsule_attr)
|
|
223
|
+
else
|
|
224
|
+
"#{capsule_attr} #{selector}"
|
|
225
|
+
end
|
|
226
|
+
end.join(", ")
|
|
227
|
+
|
|
228
|
+
leading = selectors_raw[/\A\s*/]
|
|
229
|
+
trailing = selectors_raw[/\s*\z/]
|
|
230
|
+
output << "#{leading}#{scoped_selectors}#{trailing}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
output << "{"
|
|
234
|
+
index += 1
|
|
235
|
+
brace_depth += 1
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
output
|
|
239
|
+
end
|
|
240
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
241
|
+
|
|
242
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- splits top-level CSS into hoistable and nestable parts
|
|
243
|
+
def self.hoist_top_level_at_rules(css)
|
|
244
|
+
hoisted = []
|
|
245
|
+
nestable = +""
|
|
246
|
+
index = 0
|
|
247
|
+
length = css.length
|
|
248
|
+
|
|
249
|
+
while index < length
|
|
250
|
+
while index < length && css[index].match?(/\s/)
|
|
251
|
+
nestable << css[index]
|
|
252
|
+
index += 1
|
|
253
|
+
end
|
|
254
|
+
break if index >= length
|
|
255
|
+
|
|
256
|
+
if css[index] == "@"
|
|
257
|
+
at_name = css[index..][/\A@([a-zA-Z-]+)/, 1]&.downcase
|
|
258
|
+
if at_name && HOISTABLE_AT_RULES.include?(at_name)
|
|
259
|
+
block, index = read_top_level_at_rule(css, index)
|
|
260
|
+
hoisted << block.strip
|
|
261
|
+
next
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
rule, index = read_top_level_rule(css, index)
|
|
266
|
+
nestable << rule
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
[hoisted.join("\n"), nestable]
|
|
270
|
+
end
|
|
271
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
272
|
+
|
|
273
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- semicolon vs braced at-rule forms
|
|
274
|
+
def self.read_top_level_at_rule(css, index)
|
|
275
|
+
start_index = index
|
|
276
|
+
length = css.length
|
|
277
|
+
|
|
278
|
+
while index < length && css[index] != "{" && css[index] != ";"
|
|
279
|
+
index += 1
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
if index < length && css[index] == ";"
|
|
283
|
+
index += 1
|
|
284
|
+
return [css[start_index...index], index]
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
raise ArgumentError, "Unterminated at-rule in CSS" if index >= length
|
|
288
|
+
|
|
289
|
+
index = consume_braced_block(css, index)
|
|
290
|
+
[css[start_index...index], index]
|
|
291
|
+
end
|
|
292
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
293
|
+
|
|
294
|
+
def self.read_top_level_rule(css, index)
|
|
295
|
+
start_index = index
|
|
296
|
+
length = css.length
|
|
297
|
+
|
|
298
|
+
while index < length && css[index] != "{"
|
|
299
|
+
index += 1
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
return [css[start_index...index], index] if index >= length
|
|
303
|
+
|
|
304
|
+
index = consume_braced_block(css, index)
|
|
305
|
+
[css[start_index...index], index]
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# rubocop:disable Metrics/MethodLength -- brace depth walk with unterminated guard
|
|
309
|
+
def self.consume_braced_block(css, index)
|
|
310
|
+
length = css.length
|
|
311
|
+
raise ArgumentError, "Expected opening brace in CSS" unless css[index] == "{"
|
|
312
|
+
|
|
313
|
+
depth = 0
|
|
314
|
+
while index < length
|
|
315
|
+
case css[index]
|
|
316
|
+
when "{"
|
|
317
|
+
depth += 1
|
|
318
|
+
when "}"
|
|
319
|
+
depth -= 1
|
|
320
|
+
index += 1
|
|
321
|
+
return index if depth.zero?
|
|
322
|
+
|
|
323
|
+
next
|
|
324
|
+
end
|
|
325
|
+
index += 1
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
raise ArgumentError, "Unterminated CSS block"
|
|
329
|
+
end
|
|
330
|
+
# rubocop:enable Metrics/MethodLength
|
|
331
|
+
|
|
332
|
+
private_class_method :strip_comments, :validate_capsule_id!, :patch_selectors,
|
|
333
|
+
:hoist_top_level_at_rules, :read_top_level_at_rule, :read_top_level_rule,
|
|
334
|
+
:consume_braced_block
|
|
199
335
|
end
|
|
336
|
+
# rubocop:enable Metrics/ModuleLength
|
|
200
337
|
end
|
|
@@ -32,8 +32,11 @@ module StyleCapsule
|
|
|
32
32
|
injected_body = StylesheetRegistry.inject_pending_head_stylesheets(body, view_context)
|
|
33
33
|
return [status, headers, response] if injected_body.equal?(body)
|
|
34
34
|
|
|
35
|
+
close_response(response)
|
|
36
|
+
|
|
35
37
|
headers = headers.dup
|
|
36
|
-
headers
|
|
38
|
+
headers.delete("Transfer-Encoding")
|
|
39
|
+
headers["Content-Length"] = injected_body.bytesize.to_s
|
|
37
40
|
|
|
38
41
|
[status, headers, [injected_body]]
|
|
39
42
|
end
|
|
@@ -41,19 +44,27 @@ module StyleCapsule
|
|
|
41
44
|
def inject_response?(status, headers, response)
|
|
42
45
|
return false unless SUCCESS_STATUS.cover?(status)
|
|
43
46
|
return false unless html_content_type?(headers["Content-Type"])
|
|
44
|
-
return false
|
|
45
|
-
return false if
|
|
47
|
+
return false unless bufferable_body?(response)
|
|
48
|
+
return false if empty_body?(response)
|
|
46
49
|
|
|
47
50
|
true
|
|
48
51
|
end
|
|
49
52
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
+
# Rack 3: +to_ary+ means the body may be buffered without changing application behaviour.
|
|
54
|
+
# Streaming / Live bodies omit +to_ary+; leave those alone even when Transfer-Encoding is chunked.
|
|
55
|
+
def bufferable_body?(response)
|
|
56
|
+
response.respond_to?(:to_ary)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def empty_body?(response)
|
|
60
|
+
response.to_ary == [""]
|
|
53
61
|
end
|
|
54
62
|
|
|
55
63
|
def html_content_type?(content_type)
|
|
56
|
-
|
|
64
|
+
# Core path must not call ActiveSupport String#blank?
|
|
65
|
+
# rubocop:disable Rails/Blank -- standalone hosts may lack ActiveSupport
|
|
66
|
+
return false if content_type.nil? || content_type.empty?
|
|
67
|
+
# rubocop:enable Rails/Blank
|
|
57
68
|
|
|
58
69
|
content_type.split(";", 2).first.to_s.strip.match?(HTML_CONTENT_TYPE)
|
|
59
70
|
end
|
|
@@ -64,6 +75,10 @@ module StyleCapsule
|
|
|
64
75
|
parts.join
|
|
65
76
|
end
|
|
66
77
|
|
|
78
|
+
def close_response(response)
|
|
79
|
+
response.close if response.respond_to?(:close)
|
|
80
|
+
end
|
|
81
|
+
|
|
67
82
|
def view_context_for(env)
|
|
68
83
|
controller = env["action_controller.instance"]
|
|
69
84
|
controller&.view_context
|