css_parser 1.2.2 → 3.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 +7 -0
- data/MIT-LICENSE +21 -0
- data/lib/css_parser/parser.rb +532 -185
- data/lib/css_parser/regexps.rb +220 -42
- data/lib/css_parser/rule_set.rb +491 -266
- data/lib/css_parser/version.rb +5 -0
- data/lib/css_parser.rb +56 -60
- metadata +40 -52
- data/test/fixtures/import-circular-reference.css +0 -4
- data/test/fixtures/import-with-media-types.css +0 -3
- data/test/fixtures/import1.css +0 -3
- data/test/fixtures/simple.css +0 -6
- data/test/fixtures/subdir/import2.css +0 -3
- data/test/test_css_parser_basic.rb +0 -64
- data/test/test_css_parser_loading.rb +0 -146
- data/test/test_css_parser_media_types.rb +0 -106
- data/test/test_css_parser_misc.rb +0 -164
- data/test/test_css_parser_regexps.rb +0 -69
- data/test/test_helper.rb +0 -6
- data/test/test_merging.rb +0 -110
- data/test/test_rule_set.rb +0 -90
- data/test/test_rule_set_creating_shorthand.rb +0 -143
- data/test/test_rule_set_expanding_shorthand.rb +0 -223
data/lib/css_parser/parser.rb
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'strscan'
|
|
4
|
+
|
|
1
5
|
module CssParser
|
|
2
6
|
# Exception class used for any errors encountered while downloading remote files.
|
|
3
7
|
class RemoteFileError < IOError; end
|
|
@@ -5,7 +9,6 @@ module CssParser
|
|
|
5
9
|
# Exception class used if a request is made to load a CSS file more than once.
|
|
6
10
|
class CircularReferenceError < StandardError; end
|
|
7
11
|
|
|
8
|
-
|
|
9
12
|
# == Parser class
|
|
10
13
|
#
|
|
11
14
|
# All CSS is converted to UTF-8.
|
|
@@ -14,37 +17,46 @@ module CssParser
|
|
|
14
17
|
# [<tt>absolute_paths</tt>] Convert relative paths to absolute paths (<tt>href</tt>, <tt>src</tt> and <tt>url('')</tt>. Boolean, default is <tt>false</tt>.
|
|
15
18
|
# [<tt>import</tt>] Follow <tt>@import</tt> rules. Boolean, default is <tt>true</tt>.
|
|
16
19
|
# [<tt>io_exceptions</tt>] Throw an exception if a link can not be found. Boolean, default is <tt>true</tt>.
|
|
20
|
+
# [<tt>allow_local_network</tt>] Permit http(s) fetches against loopback / private / link-local / cloud-metadata addresses. Boolean, default is <tt>false</tt>. When <tt>false</tt> (the default), outbound HTTP requests are routed through <tt>ssrf_filter</tt>, which resolves the host and rejects unsafe IP ranges. Set to <tt>true</tt> only when the destination is known to be safe (e.g. local fixture servers in tests). Independent of <tt>allow_file_uris</tt>.
|
|
21
|
+
# [<tt>allow_file_uris</tt>] Permit <tt>file://</tt> URIs via <tt>load_uri!</tt>. Boolean, default is <tt>false</tt>. When <tt>false</tt> (the default), a caller that passes a <tt>file://</tt> URI to <tt>load_uri!</tt> — directly or via a CSS <tt>@import</tt> resolved against a <tt>file://</tt> base_uri — is refused, closing the local-file-disclosure vector when the URI is influenced by user input. <tt>load_file!</tt> is unaffected: it is the explicit local-file API and takes a caller-supplied path. Independent of <tt>allow_local_network</tt>.
|
|
17
22
|
class Parser
|
|
18
|
-
USER_AGENT
|
|
19
|
-
|
|
20
|
-
STRIP_CSS_COMMENTS_RX =
|
|
21
|
-
STRIP_HTML_COMMENTS_RX =
|
|
23
|
+
USER_AGENT = "Ruby CSS Parser/#{CssParser::VERSION} (https://github.com/premailer/css_parser)".freeze
|
|
24
|
+
RULESET_TOKENIZER_RX = /\s+|\\{2,}|\\?[{}\s"]|[()]|.[^\s"{}()\\]*/.freeze
|
|
25
|
+
STRIP_CSS_COMMENTS_RX = %r{/\*.*?\*/}m.freeze
|
|
26
|
+
STRIP_HTML_COMMENTS_RX = /<!--|-->/m.freeze
|
|
22
27
|
|
|
23
28
|
# Initial parsing
|
|
24
|
-
RE_AT_IMPORT_RULE =
|
|
29
|
+
RE_AT_IMPORT_RULE = /@import\s*(?:url\s*)?(?:\()?(?:\s*)["']?([^'"\s)]*)["']?\)?([\w\s,^\]()]*)\)?[;\n]?/.freeze
|
|
25
30
|
|
|
26
|
-
|
|
27
|
-
attr_reader :loaded_uris
|
|
31
|
+
MAX_REDIRECTS = 3
|
|
28
32
|
|
|
29
|
-
#
|
|
33
|
+
# Schemes accepted by `read_remote_file`. `file://` is intentionally
|
|
34
|
+
# NOT in this list — local files are handled directly by `load_uri!`
|
|
35
|
+
# and `load_file!`. Keeping `file://` out of the remote read path
|
|
36
|
+
# closes the cross-scheme redirect (HTTP 3xx → `file://`) vector that
|
|
37
|
+
# was GHSA-9pmc-p236-855h.
|
|
38
|
+
REMOTE_ALLOWED_SCHEMES = %w[http https].freeze
|
|
30
39
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
#++
|
|
34
|
-
@folded_declaration_cache = {}
|
|
35
|
-
class << self; attr_reader :folded_declaration_cache; end
|
|
40
|
+
# Array of CSS files that have been loaded.
|
|
41
|
+
attr_reader :loaded_uris
|
|
36
42
|
|
|
37
43
|
def initialize(options = {})
|
|
38
|
-
@options = {
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
@options = {
|
|
45
|
+
absolute_paths: false,
|
|
46
|
+
import: true,
|
|
47
|
+
io_exceptions: true,
|
|
48
|
+
rule_set_exceptions: true,
|
|
49
|
+
capture_offsets: false,
|
|
50
|
+
user_agent: USER_AGENT,
|
|
51
|
+
allow_local_network: false,
|
|
52
|
+
allow_file_uris: false
|
|
53
|
+
}.merge(options)
|
|
41
54
|
|
|
42
55
|
# array of RuleSets
|
|
43
56
|
@rules = []
|
|
44
|
-
|
|
45
|
-
|
|
57
|
+
|
|
46
58
|
@loaded_uris = []
|
|
47
|
-
|
|
59
|
+
|
|
48
60
|
# unprocessed blocks of CSS
|
|
49
61
|
@blocks = []
|
|
50
62
|
reset!
|
|
@@ -68,13 +80,28 @@ module CssParser
|
|
|
68
80
|
# Returns an array of declarations.
|
|
69
81
|
def find_by_selector(selector, media_types = :all)
|
|
70
82
|
out = []
|
|
71
|
-
each_selector(media_types) do |sel, dec,
|
|
83
|
+
each_selector(media_types) do |sel, dec, _spec|
|
|
72
84
|
out << dec if sel.strip == selector.strip
|
|
73
85
|
end
|
|
74
86
|
out
|
|
75
87
|
end
|
|
76
|
-
|
|
88
|
+
alias [] find_by_selector
|
|
89
|
+
|
|
90
|
+
# Finds the rule sets that match the given selectors
|
|
91
|
+
def find_rule_sets(selectors, media_types = :all)
|
|
92
|
+
rule_sets = []
|
|
93
|
+
|
|
94
|
+
selectors.each do |selector|
|
|
95
|
+
selector = selector.gsub(/\s+/, ' ').strip
|
|
96
|
+
each_rule_set(media_types) do |rule_set, _media_type|
|
|
97
|
+
if !rule_sets.member?(rule_set) && rule_set.selectors.member?(selector)
|
|
98
|
+
rule_sets << rule_set
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
77
102
|
|
|
103
|
+
rule_sets
|
|
104
|
+
end
|
|
78
105
|
|
|
79
106
|
# Add a raw block of CSS.
|
|
80
107
|
#
|
|
@@ -97,192 +124,368 @@ module CssParser
|
|
|
97
124
|
# parser = CssParser::Parser.new
|
|
98
125
|
# parser.add_block!(css)
|
|
99
126
|
def add_block!(block, options = {})
|
|
100
|
-
options = {:
|
|
101
|
-
options[:media_types] = [options[:media_types]].flatten
|
|
102
|
-
options[:only_media_types] = [options[:only_media_types]].flatten
|
|
127
|
+
options = {base_uri: nil, base_dir: nil, charset: nil, media_types: :all, only_media_types: :all}.merge(options)
|
|
128
|
+
options[:media_types] = [options[:media_types]].flatten.collect { |mt| CssParser.sanitize_media_query(mt) }
|
|
129
|
+
options[:only_media_types] = [options[:only_media_types]].flatten.collect { |mt| CssParser.sanitize_media_query(mt) }
|
|
103
130
|
|
|
104
|
-
block = cleanup_block(block)
|
|
131
|
+
block = cleanup_block(block, options)
|
|
105
132
|
|
|
106
133
|
if options[:base_uri] and @options[:absolute_paths]
|
|
107
134
|
block = CssParser.convert_uris(block, options[:base_uri])
|
|
108
135
|
end
|
|
109
136
|
|
|
110
137
|
# Load @imported CSS
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
media_string
|
|
115
|
-
|
|
138
|
+
if @options[:import]
|
|
139
|
+
block.scan(RE_AT_IMPORT_RULE).each do |import_rule|
|
|
140
|
+
media_types = []
|
|
141
|
+
if (media_string = import_rule[-1])
|
|
142
|
+
media_string.split(',').each do |t|
|
|
143
|
+
media_types << CssParser.sanitize_media_query(t) unless t.empty?
|
|
144
|
+
end
|
|
145
|
+
else
|
|
146
|
+
media_types = [:all]
|
|
116
147
|
end
|
|
117
|
-
end
|
|
118
148
|
|
|
119
|
-
|
|
149
|
+
next unless options[:only_media_types].include?(:all) or media_types.empty? or media_types.intersect?(options[:only_media_types])
|
|
120
150
|
|
|
121
|
-
|
|
151
|
+
import_path = import_rule[0].to_s.gsub(/['"]*/, '').strip
|
|
122
152
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
153
|
+
import_options = {media_types: media_types}
|
|
154
|
+
import_options[:capture_offsets] = true if options[:capture_offsets]
|
|
155
|
+
|
|
156
|
+
if options[:base_uri]
|
|
157
|
+
import_uri = Addressable::URI.parse(options[:base_uri].to_s) + Addressable::URI.parse(import_path)
|
|
158
|
+
import_options[:base_uri] = options[:base_uri]
|
|
159
|
+
load_uri!(import_uri, import_options)
|
|
160
|
+
elsif options[:base_dir]
|
|
161
|
+
import_options[:base_dir] = options[:base_dir]
|
|
162
|
+
load_file!(import_path, import_options)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
129
165
|
end
|
|
130
166
|
|
|
131
167
|
# Remove @import declarations
|
|
132
|
-
block
|
|
133
|
-
|
|
168
|
+
block = ignore_pattern(block, RE_AT_IMPORT_RULE, options)
|
|
169
|
+
|
|
134
170
|
parse_block_into_rule_sets!(block, options)
|
|
135
171
|
end
|
|
136
172
|
|
|
137
|
-
# Add a CSS rule by setting the +selectors+, +declarations+
|
|
173
|
+
# Add a CSS rule by setting the +selectors+, +declarations+
|
|
174
|
+
# and +media_types+. Optional pass +filename+ , +offset+ for source
|
|
175
|
+
# reference too.
|
|
176
|
+
#
|
|
177
|
+
# +media_types+ can be a symbol or an array of symbols. default to :all
|
|
178
|
+
# optional fields for source location for source location
|
|
179
|
+
# +filename+ can be a string or uri pointing to the file or url location.
|
|
180
|
+
# +offset+ should be Range object representing the start and end byte locations where the rule was found in the file.
|
|
181
|
+
def add_rule!(*args, selectors: nil, block: nil, filename: nil, offset: nil, media_types: :all) # rubocop:disable Metrics/ParameterLists
|
|
182
|
+
if args.any?
|
|
183
|
+
media_types = nil
|
|
184
|
+
if selectors || block || filename || offset || media_types
|
|
185
|
+
raise ArgumentError, "don't mix positional and keyword arguments arguments"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
warn '[DEPRECATION] `add_rule!` with positional arguments is deprecated. ' \
|
|
189
|
+
'Please use keyword arguments instead.', uplevel: 1
|
|
190
|
+
|
|
191
|
+
case args.length
|
|
192
|
+
when 2
|
|
193
|
+
selectors, block = args
|
|
194
|
+
when 3
|
|
195
|
+
selectors, block, media_types = args
|
|
196
|
+
else
|
|
197
|
+
raise ArgumentError
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
begin
|
|
202
|
+
rule_set = RuleSet.new(
|
|
203
|
+
selectors: selectors, block: block,
|
|
204
|
+
offset: offset, filename: filename
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
add_rule_set!(rule_set, media_types)
|
|
208
|
+
rescue ArgumentError => e
|
|
209
|
+
raise e if @options[:rule_set_exceptions]
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Add a CSS rule by setting the +selectors+, +declarations+, +filename+, +offset+ and +media_types+.
|
|
138
214
|
#
|
|
215
|
+
# +filename+ can be a string or uri pointing to the file or url location.
|
|
216
|
+
# +offset+ should be Range object representing the start and end byte locations where the rule was found in the file.
|
|
139
217
|
# +media_types+ can be a symbol or an array of symbols.
|
|
140
|
-
def
|
|
141
|
-
|
|
142
|
-
|
|
218
|
+
def add_rule_with_offsets!(selectors, declarations, filename, offset, media_types = :all)
|
|
219
|
+
warn '[DEPRECATION] `add_rule_with_offsets!` is deprecated. Please use `add_rule!` instead.', uplevel: 1
|
|
220
|
+
add_rule!(
|
|
221
|
+
selectors: selectors, block: declarations, media_types: media_types,
|
|
222
|
+
filename: filename, offset: offset
|
|
223
|
+
)
|
|
143
224
|
end
|
|
144
225
|
|
|
145
226
|
# Add a CssParser RuleSet object.
|
|
146
227
|
#
|
|
147
228
|
# +media_types+ can be a symbol or an array of symbols.
|
|
148
229
|
def add_rule_set!(ruleset, media_types = :all)
|
|
149
|
-
raise ArgumentError unless ruleset.
|
|
230
|
+
raise ArgumentError unless ruleset.is_a?(CssParser::RuleSet)
|
|
150
231
|
|
|
151
|
-
media_types = [media_types]
|
|
232
|
+
media_types = [media_types] unless media_types.is_a?(Array)
|
|
233
|
+
media_types = media_types.flat_map { |mt| CssParser.sanitize_media_query(mt) }
|
|
152
234
|
|
|
153
|
-
@rules << {:
|
|
235
|
+
@rules << {media_types: media_types, rules: ruleset}
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Remove a CssParser RuleSet object.
|
|
239
|
+
#
|
|
240
|
+
# +media_types+ can be a symbol or an array of symbols.
|
|
241
|
+
def remove_rule_set!(ruleset, media_types = :all)
|
|
242
|
+
raise ArgumentError unless ruleset.is_a?(CssParser::RuleSet)
|
|
243
|
+
|
|
244
|
+
media_types = [media_types].flatten.collect { |mt| CssParser.sanitize_media_query(mt) }
|
|
245
|
+
|
|
246
|
+
@rules.reject! do |rule|
|
|
247
|
+
rule[:media_types] == media_types && rule[:rules].to_s == ruleset.to_s
|
|
248
|
+
end
|
|
154
249
|
end
|
|
155
250
|
|
|
156
251
|
# Iterate through RuleSet objects.
|
|
157
252
|
#
|
|
158
253
|
# +media_types+ can be a symbol or an array of symbols.
|
|
159
|
-
def each_rule_set(media_types = :all) # :yields: rule_set
|
|
254
|
+
def each_rule_set(media_types = :all) # :yields: rule_set, media_types
|
|
160
255
|
media_types = [:all] if media_types.nil?
|
|
161
|
-
media_types = [media_types]
|
|
256
|
+
media_types = [media_types].flatten.collect { |mt| CssParser.sanitize_media_query(mt) }
|
|
162
257
|
|
|
163
258
|
@rules.each do |block|
|
|
164
259
|
if media_types.include?(:all) or block[:media_types].any? { |mt| media_types.include?(mt) }
|
|
165
|
-
yield
|
|
260
|
+
yield(block[:rules], block[:media_types])
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Output all CSS rules as a Hash
|
|
266
|
+
def to_h(which_media = :all)
|
|
267
|
+
out = {}
|
|
268
|
+
styles_by_media_types = {}
|
|
269
|
+
each_selector(which_media) do |selectors, declarations, _specificity, media_types|
|
|
270
|
+
media_types.each do |media_type|
|
|
271
|
+
styles_by_media_types[media_type] ||= []
|
|
272
|
+
styles_by_media_types[media_type] << [selectors, declarations]
|
|
166
273
|
end
|
|
167
274
|
end
|
|
275
|
+
|
|
276
|
+
styles_by_media_types.each_pair do |media_type, media_styles|
|
|
277
|
+
ms = {}
|
|
278
|
+
media_styles.each do |media_style|
|
|
279
|
+
ms = css_node_to_h(ms, media_style[0], media_style[1])
|
|
280
|
+
end
|
|
281
|
+
out[media_type.to_s] = ms
|
|
282
|
+
end
|
|
283
|
+
out
|
|
168
284
|
end
|
|
169
285
|
|
|
170
286
|
# Iterate through CSS selectors.
|
|
171
287
|
#
|
|
172
288
|
# +media_types+ can be a symbol or an array of symbols.
|
|
173
289
|
# See RuleSet#each_selector for +options+.
|
|
174
|
-
def each_selector(
|
|
175
|
-
|
|
176
|
-
|
|
290
|
+
def each_selector(all_media_types = :all, options = {}) # :yields: selectors, declarations, specificity, media_types
|
|
291
|
+
return to_enum(__method__, all_media_types, options) unless block_given?
|
|
292
|
+
|
|
293
|
+
each_rule_set(all_media_types) do |rule_set, media_types|
|
|
177
294
|
rule_set.each_selector(options) do |selectors, declarations, specificity|
|
|
178
|
-
yield selectors, declarations, specificity
|
|
295
|
+
yield selectors, declarations, specificity, media_types
|
|
179
296
|
end
|
|
180
297
|
end
|
|
181
298
|
end
|
|
182
299
|
|
|
183
300
|
# Output all CSS rules as a single stylesheet.
|
|
184
|
-
def to_s(
|
|
185
|
-
out =
|
|
186
|
-
|
|
187
|
-
|
|
301
|
+
def to_s(which_media = :all)
|
|
302
|
+
out = []
|
|
303
|
+
styles_by_media_types = {}
|
|
304
|
+
|
|
305
|
+
each_selector(which_media) do |selectors, declarations, _specificity, media_types|
|
|
306
|
+
media_types.each do |media_type|
|
|
307
|
+
styles_by_media_types[media_type] ||= []
|
|
308
|
+
styles_by_media_types[media_type] << [selectors, declarations]
|
|
309
|
+
end
|
|
188
310
|
end
|
|
189
|
-
|
|
311
|
+
|
|
312
|
+
styles_by_media_types.each_pair do |media_type, media_styles|
|
|
313
|
+
media_block = (media_type != :all)
|
|
314
|
+
out << "@media #{media_type} {" if media_block
|
|
315
|
+
|
|
316
|
+
media_styles.each do |media_style|
|
|
317
|
+
if media_block
|
|
318
|
+
out.push(" #{media_style[0]} {\n #{media_style[1]}\n }")
|
|
319
|
+
else
|
|
320
|
+
out.push("#{media_style[0]} {\n#{media_style[1]}\n}")
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
out << '}' if media_block
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
out << ''
|
|
328
|
+
out.join("\n")
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# A hash of { :media_query => rule_sets }
|
|
332
|
+
def rules_by_media_query
|
|
333
|
+
rules_by_media = {}
|
|
334
|
+
@rules.each do |block|
|
|
335
|
+
block[:media_types].each do |mt|
|
|
336
|
+
unless rules_by_media.key?(mt)
|
|
337
|
+
rules_by_media[mt] = []
|
|
338
|
+
end
|
|
339
|
+
rules_by_media[mt] << block[:rules]
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
rules_by_media
|
|
190
344
|
end
|
|
191
345
|
|
|
192
346
|
# Merge declarations with the same selector.
|
|
193
347
|
def compact! # :nodoc:
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
compacted
|
|
348
|
+
[]
|
|
197
349
|
end
|
|
198
350
|
|
|
199
351
|
def parse_block_into_rule_sets!(block, options = {}) # :nodoc:
|
|
200
|
-
|
|
201
|
-
|
|
352
|
+
current_media_queries = [:all]
|
|
353
|
+
if options[:media_types]
|
|
354
|
+
current_media_queries = options[:media_types].flatten.collect { |mt| CssParser.sanitize_media_query(mt) }
|
|
355
|
+
end
|
|
202
356
|
|
|
203
357
|
in_declarations = 0
|
|
204
|
-
|
|
205
358
|
block_depth = 0
|
|
206
359
|
|
|
207
|
-
# @charset is ignored for now
|
|
208
|
-
in_charset = false
|
|
360
|
+
in_charset = false # @charset is ignored for now
|
|
209
361
|
in_string = false
|
|
210
362
|
in_at_media_rule = false
|
|
363
|
+
in_media_block = false
|
|
364
|
+
|
|
365
|
+
current_selectors = +''
|
|
366
|
+
current_media_query = +''
|
|
367
|
+
current_declarations = +''
|
|
211
368
|
|
|
212
|
-
|
|
213
|
-
|
|
369
|
+
# once we are in a rule, we will use this to store where we started if we are capturing offsets
|
|
370
|
+
rule_start = nil
|
|
371
|
+
start_offset = nil
|
|
372
|
+
end_offset = nil
|
|
214
373
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
374
|
+
scanner = StringScanner.new(block)
|
|
375
|
+
until scanner.eos?
|
|
376
|
+
# save the regex offset so that we know where in the file we are
|
|
377
|
+
start_offset = scanner.pos
|
|
378
|
+
token = scanner.scan(RULESET_TOKENIZER_RX)
|
|
379
|
+
end_offset = scanner.pos
|
|
218
380
|
|
|
219
|
-
|
|
220
|
-
if token =~ /\A"/ # found un-escaped double quote
|
|
381
|
+
if token.start_with?('"') # found un-escaped double quote
|
|
221
382
|
in_string = !in_string
|
|
222
|
-
end
|
|
383
|
+
end
|
|
223
384
|
|
|
224
385
|
if in_declarations > 0
|
|
225
|
-
|
|
226
386
|
# too deep, malformed declaration block
|
|
227
387
|
if in_declarations > 1
|
|
228
|
-
in_declarations -= 1 if token
|
|
388
|
+
in_declarations -= 1 if token.include?('}')
|
|
229
389
|
next
|
|
230
390
|
end
|
|
231
|
-
|
|
232
|
-
if
|
|
391
|
+
|
|
392
|
+
if !in_string && token.include?('{')
|
|
233
393
|
in_declarations += 1
|
|
234
394
|
next
|
|
235
395
|
end
|
|
236
|
-
|
|
237
|
-
current_declarations += token
|
|
238
396
|
|
|
239
|
-
|
|
240
|
-
current_declarations.gsub!(/\}[\s]*$/, '')
|
|
241
|
-
|
|
242
|
-
in_declarations -= 1
|
|
397
|
+
current_declarations << token
|
|
243
398
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
399
|
+
if !in_string && token.include?('}')
|
|
400
|
+
current_declarations.gsub!(/\}\s*$/, '')
|
|
401
|
+
|
|
402
|
+
in_declarations -= 1
|
|
403
|
+
current_declarations.strip!
|
|
404
|
+
|
|
405
|
+
unless current_declarations.empty?
|
|
406
|
+
add_rule_options = {
|
|
407
|
+
selectors: current_selectors, block: current_declarations,
|
|
408
|
+
media_types: current_media_queries
|
|
409
|
+
}
|
|
410
|
+
if options[:capture_offsets]
|
|
411
|
+
add_rule_options[:filename] = options[:filename]
|
|
412
|
+
add_rule_options[:offset] = rule_start..end_offset
|
|
413
|
+
end
|
|
414
|
+
add_rule!(**add_rule_options)
|
|
247
415
|
end
|
|
248
416
|
|
|
249
|
-
current_selectors = ''
|
|
250
|
-
current_declarations = ''
|
|
417
|
+
current_selectors = +''
|
|
418
|
+
current_declarations = +''
|
|
419
|
+
|
|
420
|
+
# restart our search for selectors and declarations
|
|
421
|
+
rule_start = nil if options[:capture_offsets]
|
|
251
422
|
end
|
|
252
|
-
elsif
|
|
423
|
+
elsif /@media/i.match?(token)
|
|
253
424
|
# found '@media', reset current media_types
|
|
254
425
|
in_at_media_rule = true
|
|
255
|
-
|
|
426
|
+
current_media_queries = []
|
|
256
427
|
elsif in_at_media_rule
|
|
257
|
-
if token
|
|
258
|
-
block_depth
|
|
428
|
+
if token.include?('{')
|
|
429
|
+
block_depth += 1
|
|
259
430
|
in_at_media_rule = false
|
|
431
|
+
in_media_block = true
|
|
432
|
+
current_media_queries << CssParser.sanitize_media_query(current_media_query)
|
|
433
|
+
current_media_query = +''
|
|
434
|
+
elsif token.include?(',')
|
|
435
|
+
# new media query begins
|
|
436
|
+
token.tr!(',', ' ')
|
|
437
|
+
token.strip!
|
|
438
|
+
current_media_query << token << ' '
|
|
439
|
+
current_media_queries << CssParser.sanitize_media_query(current_media_query)
|
|
440
|
+
current_media_query = +''
|
|
260
441
|
else
|
|
261
|
-
token.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
block_depth = block_depth - 1
|
|
270
|
-
else
|
|
271
|
-
if token =~ /\{/ and not in_string
|
|
272
|
-
current_selectors.gsub!(/^[\s]*/, '')
|
|
273
|
-
current_selectors.gsub!(/[\s]*$/, '')
|
|
274
|
-
in_declarations += 1
|
|
442
|
+
token.strip!
|
|
443
|
+
# special-case the ( and ) tokens to remove inner-whitespace
|
|
444
|
+
# (eg we'd prefer '(width: 500px)' to '( width: 500px )' )
|
|
445
|
+
case token
|
|
446
|
+
when '('
|
|
447
|
+
current_media_query << token
|
|
448
|
+
when ')'
|
|
449
|
+
current_media_query.sub!(/ ?$/, token)
|
|
275
450
|
else
|
|
276
|
-
|
|
451
|
+
current_media_query << token << ' '
|
|
277
452
|
end
|
|
278
453
|
end
|
|
454
|
+
elsif in_charset or /@charset/i.match?(token)
|
|
455
|
+
# iterate until we are out of the charset declaration
|
|
456
|
+
in_charset = !token.include?(';')
|
|
457
|
+
elsif !in_string && token.include?('}')
|
|
458
|
+
block_depth -= 1
|
|
459
|
+
|
|
460
|
+
# reset the current media query scope
|
|
461
|
+
if in_media_block
|
|
462
|
+
current_media_queries = [:all]
|
|
463
|
+
in_media_block = false
|
|
464
|
+
end
|
|
465
|
+
elsif !in_string && token.include?('{')
|
|
466
|
+
current_selectors.strip!
|
|
467
|
+
in_declarations += 1
|
|
468
|
+
else
|
|
469
|
+
# if we are in a selector, add the token to the current selectors
|
|
470
|
+
current_selectors << token
|
|
471
|
+
|
|
472
|
+
# mark this as the beginning of the selector unless we have already marked it
|
|
473
|
+
rule_start = start_offset if options[:capture_offsets] && rule_start.nil? && /^[^\s]+$/.match?(token)
|
|
279
474
|
end
|
|
280
475
|
end
|
|
281
476
|
|
|
282
|
-
# check for unclosed braces
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
477
|
+
# check for unclosed braces
|
|
478
|
+
return unless in_declarations > 0
|
|
479
|
+
|
|
480
|
+
add_rule_options = {
|
|
481
|
+
selectors: current_selectors, block: current_declarations,
|
|
482
|
+
media_types: current_media_queries
|
|
483
|
+
}
|
|
484
|
+
if options[:capture_offsets]
|
|
485
|
+
add_rule_options[:filename] = options[:filename]
|
|
486
|
+
add_rule_options[:offset] = rule_start..end_offset
|
|
487
|
+
end
|
|
488
|
+
add_rule!(**add_rule_options)
|
|
286
489
|
end
|
|
287
490
|
|
|
288
491
|
# Load a remote CSS file.
|
|
@@ -294,18 +497,18 @@ module CssParser
|
|
|
294
497
|
# Deprecated: originally accepted three params: `uri`, `base_uri` and `media_types`
|
|
295
498
|
def load_uri!(uri, options = {}, deprecated = nil)
|
|
296
499
|
uri = Addressable::URI.parse(uri) unless uri.respond_to? :scheme
|
|
297
|
-
#base_uri = nil, media_types = :all, options = {}
|
|
298
500
|
|
|
299
|
-
opts = {:
|
|
501
|
+
opts = {base_uri: nil, media_types: :all}
|
|
300
502
|
|
|
301
503
|
if options.is_a? Hash
|
|
302
504
|
opts.merge!(options)
|
|
303
505
|
else
|
|
506
|
+
warn '[DEPRECATION] `load_uri!` with positional arguments is deprecated. ' \
|
|
507
|
+
'Please use keyword arguments instead.', uplevel: 1
|
|
304
508
|
opts[:base_uri] = options if options.is_a? String
|
|
305
509
|
opts[:media_types] = deprecated if deprecated
|
|
306
510
|
end
|
|
307
|
-
|
|
308
|
-
|
|
511
|
+
|
|
309
512
|
if uri.scheme == 'file' or uri.scheme.nil?
|
|
310
513
|
uri.path = File.expand_path(uri.path)
|
|
311
514
|
uri.scheme = 'file'
|
|
@@ -313,121 +516,246 @@ module CssParser
|
|
|
313
516
|
|
|
314
517
|
opts[:base_uri] = uri if opts[:base_uri].nil?
|
|
315
518
|
|
|
316
|
-
|
|
519
|
+
# pass on the uri if we are capturing file offsets
|
|
520
|
+
opts[:filename] = uri.to_s if opts[:capture_offsets]
|
|
521
|
+
|
|
522
|
+
# file:// is handled here, not inside read_remote_file. The
|
|
523
|
+
# remote-read path must never service file:// URIs, so a 3xx
|
|
524
|
+
# `Location: file://...` redirect cannot be turned into a local
|
|
525
|
+
# File.read.
|
|
526
|
+
#
|
|
527
|
+
# file:// via `load_uri!` is also gated by `allow_file_uris`:
|
|
528
|
+
# an attacker who can influence a URI passed here (e.g. via a CSS
|
|
529
|
+
# @import resolved against an attacker-controlled base_uri) could
|
|
530
|
+
# otherwise turn it into arbitrary local file disclosure. Callers
|
|
531
|
+
# that legitimately need to load local files should use
|
|
532
|
+
# `load_file!` (the explicit local-file API).
|
|
533
|
+
src = if uri.scheme == 'file'
|
|
534
|
+
unless @options[:allow_file_uris]
|
|
535
|
+
raise RemoteFileError, uri.to_s if @options[:io_exceptions]
|
|
536
|
+
|
|
537
|
+
return
|
|
538
|
+
end
|
|
539
|
+
read_local_file(uri)
|
|
540
|
+
else
|
|
541
|
+
src_and_charset, = read_remote_file(uri) # skip charset
|
|
542
|
+
src_and_charset
|
|
543
|
+
end
|
|
317
544
|
|
|
318
|
-
if src
|
|
319
|
-
add_block!(src, opts)
|
|
320
|
-
end
|
|
545
|
+
add_block!(src, opts) if src
|
|
321
546
|
end
|
|
322
|
-
|
|
547
|
+
|
|
323
548
|
# Load a local CSS file.
|
|
324
|
-
def load_file!(file_name,
|
|
325
|
-
|
|
549
|
+
def load_file!(file_name, options = {}, deprecated = nil)
|
|
550
|
+
opts = {base_dir: nil, media_types: :all}
|
|
551
|
+
|
|
552
|
+
if options.is_a? Hash
|
|
553
|
+
opts.merge!(options)
|
|
554
|
+
else
|
|
555
|
+
warn '[DEPRECATION] `load_file!` with positional arguments is deprecated. ' \
|
|
556
|
+
'Please use keyword arguments instead.', uplevel: 1
|
|
557
|
+
opts[:base_dir] = options if options.is_a? String
|
|
558
|
+
opts[:media_types] = deprecated if deprecated
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
file_name = File.expand_path(file_name, opts[:base_dir])
|
|
326
562
|
return unless File.readable?(file_name)
|
|
327
563
|
return unless circular_reference_check(file_name)
|
|
328
564
|
|
|
329
|
-
src =
|
|
330
|
-
|
|
565
|
+
src = File.read(file_name)
|
|
566
|
+
|
|
567
|
+
opts[:filename] = file_name if opts[:capture_offsets]
|
|
568
|
+
opts[:base_dir] = File.dirname(file_name)
|
|
569
|
+
|
|
570
|
+
add_block!(src, opts)
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# Load a local CSS string.
|
|
574
|
+
def load_string!(src, options = {}, deprecated = nil)
|
|
575
|
+
opts = {base_dir: nil, media_types: :all}
|
|
576
|
+
|
|
577
|
+
if options.is_a? Hash
|
|
578
|
+
opts.merge!(options)
|
|
579
|
+
else
|
|
580
|
+
warn '[DEPRECATION] `load_file!` with positional arguments is deprecated. ' \
|
|
581
|
+
'Please use keyword arguments instead.', uplevel: 1
|
|
582
|
+
opts[:base_dir] = options if options.is_a? String
|
|
583
|
+
opts[:media_types] = deprecated if deprecated
|
|
584
|
+
end
|
|
331
585
|
|
|
332
|
-
add_block!(src,
|
|
586
|
+
add_block!(src, opts)
|
|
333
587
|
end
|
|
334
|
-
|
|
335
|
-
|
|
336
588
|
|
|
337
589
|
protected
|
|
590
|
+
|
|
338
591
|
# Check that a path hasn't been loaded already
|
|
339
592
|
#
|
|
340
|
-
# Raises a CircularReferenceError exception if io_exceptions are on,
|
|
593
|
+
# Raises a CircularReferenceError exception if io_exceptions are on,
|
|
341
594
|
# otherwise returns true/false.
|
|
342
|
-
|
|
595
|
+
# TODO: fix rubocop
|
|
596
|
+
def circular_reference_check(path) # rubocop:disable Naming/PredicateMethod
|
|
343
597
|
path = path.to_s
|
|
344
598
|
if @loaded_uris.include?(path)
|
|
345
599
|
raise CircularReferenceError, "can't load #{path} more than once" if @options[:io_exceptions]
|
|
346
|
-
|
|
600
|
+
|
|
601
|
+
false
|
|
347
602
|
else
|
|
348
603
|
@loaded_uris << path
|
|
349
|
-
|
|
604
|
+
true
|
|
350
605
|
end
|
|
351
606
|
end
|
|
352
|
-
|
|
607
|
+
|
|
608
|
+
# Remove a pattern from a given string
|
|
609
|
+
#
|
|
610
|
+
# Returns a string.
|
|
611
|
+
def ignore_pattern(css, regex, options)
|
|
612
|
+
# if we are capturing file offsets, replace the characters with spaces to retail the original positions
|
|
613
|
+
return css.gsub(regex) { |m| ' ' * m.length } if options[:capture_offsets]
|
|
614
|
+
|
|
615
|
+
# otherwise just strip it out
|
|
616
|
+
css.gsub(regex, '')
|
|
617
|
+
end
|
|
618
|
+
|
|
353
619
|
# Strip comments and clean up blank lines from a block of CSS.
|
|
354
620
|
#
|
|
355
621
|
# Returns a string.
|
|
356
|
-
def cleanup_block(block) # :nodoc:
|
|
622
|
+
def cleanup_block(block, options = {}) # :nodoc:
|
|
357
623
|
# Strip CSS comments
|
|
358
|
-
block.
|
|
624
|
+
utf8_block = block.encode('UTF-8', 'UTF-8', invalid: :replace, undef: :replace, replace: ' ')
|
|
625
|
+
utf8_block = ignore_pattern(utf8_block, STRIP_CSS_COMMENTS_RX, options)
|
|
359
626
|
|
|
360
|
-
# Strip HTML comments - they shouldn't really be in here but
|
|
627
|
+
# Strip HTML comments - they shouldn't really be in here but
|
|
361
628
|
# some people are just crazy...
|
|
362
|
-
|
|
629
|
+
utf8_block = ignore_pattern(utf8_block, STRIP_HTML_COMMENTS_RX, options)
|
|
363
630
|
|
|
364
631
|
# Strip lines containing just whitespace
|
|
365
|
-
|
|
632
|
+
utf8_block.gsub!(/^\s+$/, '') unless options[:capture_offsets]
|
|
633
|
+
|
|
634
|
+
utf8_block
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
# Read a local file:// URI. Called only from `load_uri!` — never
|
|
638
|
+
# from the remote read path — so an HTTP redirect cannot reach this
|
|
639
|
+
# branch (GHSA-9pmc-p236-855h).
|
|
640
|
+
def read_local_file(uri) # :nodoc:
|
|
641
|
+
# Internal invariant: this method is the implementation of the
|
|
642
|
+
# `allow_file_uris: true` branch of `load_uri!`. If it is ever
|
|
643
|
+
# reached without that flag set, a future change has bypassed the
|
|
644
|
+
# LFI gate; refuse to read rather than silently leak.
|
|
645
|
+
unless @options[:allow_file_uris]
|
|
646
|
+
raise "BUG: #{self.class}##{__method__} reached with " \
|
|
647
|
+
'allow_file_uris=false (LFI gate bypassed)'
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
return nil unless circular_reference_check(uri.to_s)
|
|
651
|
+
|
|
652
|
+
path = uri.path
|
|
653
|
+
path.gsub!(%r{^/}, '') if Gem.win_platform?
|
|
654
|
+
File.read(path, mode: 'rb')
|
|
655
|
+
rescue
|
|
656
|
+
raise RemoteFileError, uri.to_s if @options[:io_exceptions]
|
|
366
657
|
|
|
367
|
-
|
|
658
|
+
nil
|
|
368
659
|
end
|
|
369
660
|
|
|
370
|
-
# Download a file into a string.
|
|
661
|
+
# Download a remote http(s) file into a string.
|
|
371
662
|
#
|
|
372
663
|
# Returns the file's data and character set in an array.
|
|
664
|
+
#
|
|
665
|
+
# In the default (secure) configuration, requests are issued via
|
|
666
|
+
# `SsrfFilter.get`, which:
|
|
667
|
+
# - rejects any scheme other than http/https (defeats redirect-to-
|
|
668
|
+
# `file://` / `gopher://` / `dict://` etc.);
|
|
669
|
+
# - resolves the hostname with `Resolv` and rejects requests whose
|
|
670
|
+
# resolved IP is loopback, RFC-1918, link-local, multicast, or any
|
|
671
|
+
# other range typically used for internal services (defeats SSRF
|
|
672
|
+
# via literal IPs and via CNAME / attacker-controlled A records);
|
|
673
|
+
# - re-validates scheme and IP on every redirect hop.
|
|
674
|
+
#
|
|
675
|
+
# When `allow_local_network: true` is set on the Parser, the SSRF
|
|
676
|
+
# check is bypassed and plain `Net::HTTP` is used — but the scheme
|
|
677
|
+
# is still validated on every redirect hop, so cross-scheme
|
|
678
|
+
# redirect to `file://` (the original GHSA-9pmc-p236-855h sink)
|
|
679
|
+
# remains closed even on this opt-in path.
|
|
373
680
|
#--
|
|
374
681
|
# TODO: add option to fail silently or throw and exception on a 404
|
|
375
682
|
#++
|
|
376
683
|
def read_remote_file(uri) # :nodoc:
|
|
377
|
-
|
|
684
|
+
uri = Addressable::URI.parse(uri.to_s)
|
|
378
685
|
|
|
379
|
-
|
|
686
|
+
unless circular_reference_check(uri.to_s)
|
|
687
|
+
return nil, nil
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
unless REMOTE_ALLOWED_SCHEMES.include?(uri.scheme)
|
|
691
|
+
raise RemoteFileError, uri.to_s if @options[:io_exceptions]
|
|
692
|
+
|
|
693
|
+
return nil, nil
|
|
694
|
+
end
|
|
380
695
|
|
|
381
696
|
begin
|
|
382
|
-
|
|
697
|
+
res = if @options[:allow_local_network]
|
|
698
|
+
fetch_via_net_http(uri)
|
|
699
|
+
else
|
|
700
|
+
SsrfFilter.get(
|
|
701
|
+
uri.to_s,
|
|
702
|
+
scheme_whitelist: REMOTE_ALLOWED_SCHEMES,
|
|
703
|
+
max_redirects: MAX_REDIRECTS,
|
|
704
|
+
headers: {'User-Agent' => @options[:user_agent]}
|
|
705
|
+
)
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
if res.code.to_i >= 400
|
|
709
|
+
raise RemoteFileError, uri.to_s if @options[:io_exceptions]
|
|
710
|
+
|
|
711
|
+
return '', nil
|
|
712
|
+
end
|
|
383
713
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
src = fh.read
|
|
388
|
-
fh.close
|
|
389
|
-
else
|
|
390
|
-
# remote file
|
|
391
|
-
if uri.scheme == 'https'
|
|
392
|
-
uri.port = 443 unless uri.port
|
|
393
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
394
|
-
http.use_ssl = true
|
|
395
|
-
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
396
|
-
else
|
|
397
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
398
|
-
end
|
|
714
|
+
charset = res.respond_to?(:charset) ? res.encoding : 'utf-8'
|
|
715
|
+
src = res.body
|
|
716
|
+
src.encode!('UTF-8', charset) if charset
|
|
399
717
|
|
|
400
|
-
|
|
401
|
-
|
|
718
|
+
[src, charset]
|
|
719
|
+
rescue
|
|
720
|
+
raise RemoteFileError, uri.to_s if @options[:io_exceptions]
|
|
402
721
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
end
|
|
722
|
+
[nil, nil]
|
|
723
|
+
end
|
|
724
|
+
end
|
|
407
725
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
726
|
+
# Net::HTTP path used only when `allow_local_network: true`. Validates
|
|
727
|
+
# the URI scheme on every redirect hop so a `Location: file://...`
|
|
728
|
+
# cannot be followed even on this opt-in code path.
|
|
729
|
+
def fetch_via_net_http(uri, redirect_count = 0) # :nodoc:
|
|
730
|
+
# Internal invariant: this method is the implementation of the
|
|
731
|
+
# `allow_local_network: true` branch of `read_remote_file`. If it
|
|
732
|
+
# is ever reached without that flag set, a future change has
|
|
733
|
+
# bypassed the SSRF gate; refuse to fetch rather than silently
|
|
734
|
+
# connect. The recursive call on a redirect inherits this guard
|
|
735
|
+
# because the option does not change mid-request.
|
|
736
|
+
unless @options[:allow_local_network]
|
|
737
|
+
raise "BUG: #{self.class}##{__method__} reached with " \
|
|
738
|
+
'allow_local_network=false (SSRF gate bypassed)'
|
|
739
|
+
end
|
|
417
740
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
741
|
+
raise RemoteFileError, uri.to_s unless REMOTE_ALLOWED_SCHEMES.include?(uri.scheme)
|
|
742
|
+
raise RemoteFileError, uri.to_s if redirect_count > MAX_REDIRECTS
|
|
743
|
+
|
|
744
|
+
http = Net::HTTP.new(uri.host, uri.port || uri.default_port)
|
|
745
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
746
|
+
|
|
747
|
+
res = http.get(uri.request_uri, {'User-Agent' => @options[:user_agent]})
|
|
748
|
+
|
|
749
|
+
if res.code.to_i >= 300 && res.code.to_i < 400 && res['Location']
|
|
750
|
+
redirect_uri = Addressable::URI.parse(Addressable::URI.escape(res['Location']))
|
|
751
|
+
return fetch_via_net_http(redirect_uri, redirect_count + 1)
|
|
425
752
|
end
|
|
426
753
|
|
|
427
|
-
|
|
754
|
+
res
|
|
428
755
|
end
|
|
429
756
|
|
|
430
757
|
private
|
|
758
|
+
|
|
431
759
|
# Save a folded declaration block to the internal cache.
|
|
432
760
|
def save_folded_declaration(block_hash, folded_declaration) # :nodoc:
|
|
433
761
|
@folded_declaration_cache[block_hash] = folded_declaration
|
|
@@ -435,7 +763,7 @@ module CssParser
|
|
|
435
763
|
|
|
436
764
|
# Retrieve a folded declaration block from the internal cache.
|
|
437
765
|
def get_folded_declaration(block_hash) # :nodoc:
|
|
438
|
-
|
|
766
|
+
@folded_declaration_cache[block_hash] ||= nil
|
|
439
767
|
end
|
|
440
768
|
|
|
441
769
|
def reset! # :nodoc:
|
|
@@ -444,5 +772,24 @@ module CssParser
|
|
|
444
772
|
@css_rules = []
|
|
445
773
|
@css_warnings = []
|
|
446
774
|
end
|
|
775
|
+
|
|
776
|
+
# recurse through nested nodes and return them as Hashes nested in
|
|
777
|
+
# passed hash
|
|
778
|
+
def css_node_to_h(hash, key, val)
|
|
779
|
+
hash[key.strip] = '' and return hash if val.nil?
|
|
780
|
+
|
|
781
|
+
lines = val.split(';')
|
|
782
|
+
nodes = {}
|
|
783
|
+
lines.each do |line|
|
|
784
|
+
parts = line.split(':', 2)
|
|
785
|
+
if parts[1].include?(':')
|
|
786
|
+
nodes[parts[0]] = css_node_to_h(hash, parts[0], parts[1])
|
|
787
|
+
else
|
|
788
|
+
nodes[parts[0].to_s.strip] = parts[1].to_s.strip
|
|
789
|
+
end
|
|
790
|
+
end
|
|
791
|
+
hash[key.strip] = nodes
|
|
792
|
+
hash
|
|
793
|
+
end
|
|
447
794
|
end
|
|
448
795
|
end
|