kajabi-css_parser 1.2.7

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,167 @@
1
+ require 'addressable/uri'
2
+ require 'uri'
3
+ require 'net/https'
4
+ require 'open-uri'
5
+ require 'digest/md5'
6
+ require 'zlib'
7
+ require 'stringio'
8
+ require 'iconv' unless String.method_defined?(:encode)
9
+
10
+ module CssParser
11
+ VERSION = '1.2.7'
12
+
13
+ # Merge multiple CSS RuleSets by cascading according to the CSS 2.1 cascading rules
14
+ # (http://www.w3.org/TR/REC-CSS2/cascade.html#cascading-order).
15
+ #
16
+ # Takes one or more RuleSet objects.
17
+ #
18
+ # Returns a RuleSet.
19
+ #
20
+ # ==== Cascading
21
+ # If a RuleSet object has its +specificity+ defined, that specificity is
22
+ # used in the cascade calculations.
23
+ #
24
+ # If no specificity is explicitly set and the RuleSet has *one* selector,
25
+ # the specificity is calculated using that selector.
26
+ #
27
+ # If no selectors or multiple selectors are present, the specificity is
28
+ # treated as 0.
29
+ #
30
+ # ==== Example #1
31
+ # rs1 = RuleSet.new(nil, 'color: black;')
32
+ # rs2 = RuleSet.new(nil, 'margin: 0px;')
33
+ #
34
+ # merged = CssParser.merge(rs1, rs2)
35
+ #
36
+ # puts merged
37
+ # => "{ margin: 0px; color: black; }"
38
+ #
39
+ # ==== Example #2
40
+ # rs1 = RuleSet.new(nil, 'background-color: black;')
41
+ # rs2 = RuleSet.new(nil, 'background-image: none;')
42
+ #
43
+ # merged = CssParser.merge(rs1, rs2)
44
+ #
45
+ # puts merged
46
+ # => "{ background: none black; }"
47
+ #--
48
+ # TODO: declaration_hashes should be able to contain a RuleSet
49
+ # this should be a Class method
50
+ def CssParser.merge(*rule_sets)
51
+ @folded_declaration_cache = {}
52
+
53
+ # in case called like CssParser.merge([rule_set, rule_set])
54
+ rule_sets.flatten! if rule_sets[0].kind_of?(Array)
55
+
56
+ unless rule_sets.all? {|rs| rs.kind_of?(CssParser::RuleSet)}
57
+ raise ArgumentError, "all parameters must be CssParser::RuleSets."
58
+ end
59
+
60
+ return rule_sets[0] if rule_sets.length == 1
61
+
62
+ # Internal storage of CSS properties that we will keep
63
+ properties = {}
64
+
65
+ rule_sets.each do |rule_set|
66
+ rule_set.expand_shorthand!
67
+
68
+ specificity = rule_set.specificity
69
+ unless specificity
70
+ if rule_set.selectors.length == 1
71
+ specificity = calculate_specificity(rule_set.selectors[0])
72
+ else
73
+ specificity = 0
74
+ end
75
+ end
76
+
77
+ rule_set.each_declaration do |property, value, is_important|
78
+ # Add the property to the list to be folded per http://www.w3.org/TR/CSS21/cascade.html#cascading-order
79
+ if not properties.has_key?(property)
80
+ properties[property] = {:value => value, :specificity => specificity, :is_important => is_important}
81
+ elsif is_important
82
+ if not properties[property][:is_important] or properties[property][:specificity] <= specificity
83
+ properties[property] = {:value => value, :specificity => specificity, :is_important => is_important}
84
+ end
85
+ elsif properties[property][:specificity] < specificity or properties[property][:specificity] == specificity
86
+ unless properties[property][:is_important]
87
+ properties[property] = {:value => value, :specificity => specificity, :is_important => is_important}
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ merged = RuleSet.new(nil, nil)
94
+
95
+ properties.each do |property, details|
96
+ if details[:is_important]
97
+ merged[property.strip] = details[:value].strip.gsub(/\;\Z/, '') + '!important'
98
+ else
99
+ merged[property.strip] = details[:value].strip
100
+ end
101
+ end
102
+
103
+ merged.create_shorthand!
104
+ merged
105
+ end
106
+
107
+ # Calculates the specificity of a CSS selector
108
+ # per http://www.w3.org/TR/CSS21/cascade.html#specificity
109
+ #
110
+ # Returns an integer.
111
+ #
112
+ # ==== Example
113
+ # CssParser.calculate_specificity('#content div p:first-line a:link')
114
+ # => 114
115
+ #--
116
+ # Thanks to Rafael Salazar and Nick Fitzsimons on the css-discuss list for their help.
117
+ #++
118
+ def CssParser.calculate_specificity(selector)
119
+ a = 0
120
+ b = selector.scan(/\#/).length
121
+ c = selector.scan(NON_ID_ATTRIBUTES_AND_PSEUDO_CLASSES_RX).length
122
+ d = selector.scan(ELEMENTS_AND_PSEUDO_ELEMENTS_RX).length
123
+
124
+ (a.to_s + b.to_s + c.to_s + d.to_s).to_i
125
+ rescue
126
+ return 0
127
+ end
128
+
129
+ # Make <tt>url()</tt> links absolute.
130
+ #
131
+ # Takes a block of CSS and returns it with all relative URIs converted to absolute URIs.
132
+ #
133
+ # "For CSS style sheets, the base URI is that of the style sheet, not that of the source document."
134
+ # per http://www.w3.org/TR/CSS21/syndata.html#uri
135
+ #
136
+ # Returns a string.
137
+ #
138
+ # ==== Example
139
+ # CssParser.convert_uris("body { background: url('../style/yellow.png?abc=123') };",
140
+ # "http://example.org/style/basic.css").inspect
141
+ # => "body { background: url('http://example.org/style/yellow.png?abc=123') };"
142
+ def self.convert_uris(css, base_uri)
143
+ base_uri = Addressable::URI.parse(base_uri) unless base_uri.kind_of?(Addressable::URI)
144
+
145
+ css.gsub(URI_RX) do
146
+ uri = $1.to_s
147
+ uri.gsub!(/["']+/, '')
148
+ # Don't process URLs that are already absolute
149
+ unless uri =~ /^[a-z]+\:\/\//i
150
+ begin
151
+ uri = base_uri + uri
152
+ rescue; end
153
+ end
154
+ "url('#{uri.to_s}')"
155
+ end
156
+ end
157
+
158
+ def self.sanitize_media_query(raw)
159
+ mq = raw.to_s.gsub(/[\s]+/, ' ').strip
160
+ mq = 'all' if mq.empty?
161
+ mq.to_sym
162
+ end
163
+ end
164
+
165
+ require File.dirname(__FILE__) + '/css_parser/rule_set'
166
+ require File.dirname(__FILE__) + '/css_parser/regexps'
167
+ require File.dirname(__FILE__) + '/css_parser/parser'
@@ -0,0 +1,477 @@
1
+ module CssParser
2
+ # Exception class used for any errors encountered while downloading remote files.
3
+ class RemoteFileError < IOError; end
4
+
5
+ # Exception class used if a request is made to load a CSS file more than once.
6
+ class CircularReferenceError < StandardError; end
7
+
8
+
9
+ # == Parser class
10
+ #
11
+ # All CSS is converted to UTF-8.
12
+ #
13
+ # When calling Parser#new there are some configuaration options:
14
+ # [<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
+ # [<tt>import</tt>] Follow <tt>@import</tt> rules. Boolean, default is <tt>true</tt>.
16
+ # [<tt>io_exceptions</tt>] Throw an exception if a link can not be found. Boolean, default is <tt>true</tt>.
17
+ class Parser
18
+ USER_AGENT = "Ruby CSS Parser/#{CssParser::VERSION} (http://github.com/alexdunae/css_parser)"
19
+
20
+ STRIP_CSS_COMMENTS_RX = /\/\*.*?\*\//m
21
+ STRIP_HTML_COMMENTS_RX = /\<\!\-\-|\-\-\>/m
22
+
23
+ # Initial parsing
24
+ RE_AT_IMPORT_RULE = /\@import\s*(?:url\s*)?(?:\()?(?:\s*)["']?([^'"\s\)]*)["']?\)?([\w\s\,^\]\(\))]*)\)?[;\n]?/
25
+
26
+ # Array of CSS files that have been loaded.
27
+ attr_reader :loaded_uris
28
+
29
+ #--
30
+ # Class variable? see http://www.oreillynet.com/ruby/blog/2007/01/nubygems_dont_use_class_variab_1.html
31
+ #++
32
+ @folded_declaration_cache = {}
33
+ class << self; attr_reader :folded_declaration_cache; end
34
+
35
+ def initialize(options = {})
36
+ @options = {:absolute_paths => false,
37
+ :import => true,
38
+ :io_exceptions => true}.merge(options)
39
+
40
+ # array of RuleSets
41
+ @rules = []
42
+
43
+
44
+ @loaded_uris = []
45
+
46
+ # unprocessed blocks of CSS
47
+ @blocks = []
48
+ reset!
49
+ end
50
+
51
+ # Get declarations by selector.
52
+ #
53
+ # +media_types+ are optional, and can be a symbol or an array of symbols.
54
+ # The default value is <tt>:all</tt>.
55
+ #
56
+ # ==== Examples
57
+ # find_by_selector('#content')
58
+ # => 'font-size: 13px; line-height: 1.2;'
59
+ #
60
+ # find_by_selector('#content', [:screen, :handheld])
61
+ # => 'font-size: 13px; line-height: 1.2;'
62
+ #
63
+ # find_by_selector('#content', :print)
64
+ # => 'font-size: 11pt; line-height: 1.2;'
65
+ #
66
+ # Returns an array of declarations.
67
+ def find_by_selector(selector, media_types = :all)
68
+ out = []
69
+ each_selector(media_types) do |sel, dec, spec|
70
+ out << dec if sel.strip == selector.strip
71
+ end
72
+ out
73
+ end
74
+ alias_method :[], :find_by_selector
75
+
76
+
77
+ # Add a raw block of CSS.
78
+ #
79
+ # In order to follow +@import+ rules you must supply either a
80
+ # +:base_dir+ or +:base_uri+ option.
81
+ #
82
+ # Use the +:media_types+ option to set the media type(s) for this block. Takes an array of symbols.
83
+ #
84
+ # Use the +:only_media_types+ option to selectively follow +@import+ rules. Takes an array of symbols.
85
+ #
86
+ # ==== Example
87
+ # css = <<-EOT
88
+ # body { font-size: 10pt }
89
+ # p { margin: 0px; }
90
+ # @media screen, print {
91
+ # body { line-height: 1.2 }
92
+ # }
93
+ # EOT
94
+ #
95
+ # parser = CssParser::Parser.new
96
+ # parser.add_block!(css)
97
+ def add_block!(block, options = {})
98
+ options = {:base_uri => nil, :base_dir => nil, :charset => nil, :media_types => :all, :only_media_types => :all}.merge(options)
99
+ options[:media_types] = [options[:media_types]].flatten.collect { |mt| CssParser.sanitize_media_query(mt)}
100
+ options[:only_media_types] = [options[:only_media_types]].flatten.collect { |mt| CssParser.sanitize_media_query(mt)}
101
+
102
+ block = cleanup_block(block)
103
+
104
+ if options[:base_uri] and @options[:absolute_paths]
105
+ block = CssParser.convert_uris(block, options[:base_uri])
106
+ end
107
+
108
+ # Load @imported CSS
109
+ block.scan(RE_AT_IMPORT_RULE).each do |import_rule|
110
+ media_types = []
111
+ if media_string = import_rule[-1]
112
+ media_string.split(/[,]/).each do |t|
113
+ media_types << CssParser.sanitize_media_query(t) unless t.empty?
114
+ end
115
+ else
116
+ media_types = [:all]
117
+ end
118
+
119
+ next unless options[:only_media_types].include?(:all) or media_types.length < 1 or (media_types & options[:only_media_types]).length > 0
120
+
121
+ import_path = import_rule[0].to_s.gsub(/['"]*/, '').strip
122
+
123
+ if options[:base_uri]
124
+ import_uri = Addressable::URI.parse(options[:base_uri].to_s) + Addressable::URI.parse(import_path)
125
+ load_uri!(import_uri, options[:base_uri], media_types)
126
+ elsif options[:base_dir]
127
+ load_file!(import_path, options[:base_dir], media_types)
128
+ end
129
+ end
130
+
131
+ # Remove @import declarations
132
+ block.gsub!(RE_AT_IMPORT_RULE, '')
133
+
134
+ parse_block_into_rule_sets!(block, options)
135
+ end
136
+
137
+ # Add a CSS rule by setting the +selectors+, +declarations+ and +media_types+.
138
+ #
139
+ # +media_types+ can be a symbol or an array of symbols.
140
+ def add_rule!(selectors, declarations, media_types = :all)
141
+ rule_set = RuleSet.new(selectors, declarations)
142
+ add_rule_set!(rule_set, media_types)
143
+ end
144
+
145
+ # Add a CssParser RuleSet object.
146
+ #
147
+ # +media_types+ can be a symbol or an array of symbols.
148
+ def add_rule_set!(ruleset, media_types = :all)
149
+ raise ArgumentError unless ruleset.kind_of?(CssParser::RuleSet)
150
+
151
+ media_types = [media_types].flatten.collect { |mt| CssParser.sanitize_media_query(mt)}
152
+
153
+ @rules << {:media_types => media_types, :rules => ruleset}
154
+ end
155
+
156
+ # Iterate through RuleSet objects.
157
+ #
158
+ # +media_types+ can be a symbol or an array of symbols.
159
+ def each_rule_set(media_types = :all) # :yields: rule_set
160
+ media_types = [:all] if media_types.nil?
161
+ media_types = [media_types].flatten.collect { |mt| CssParser.sanitize_media_query(mt)}
162
+
163
+ @rules.each do |block|
164
+ if media_types.include?(:all) or block[:media_types].any? { |mt| media_types.include?(mt) }
165
+ yield block[:rules]
166
+ end
167
+ end
168
+ end
169
+
170
+ # Iterate through CSS selectors.
171
+ #
172
+ # +media_types+ can be a symbol or an array of symbols.
173
+ # See RuleSet#each_selector for +options+.
174
+ def each_selector(media_types = :all, options = {}) # :yields: selectors, declarations, specificity
175
+ each_rule_set(media_types) do |rule_set|
176
+ rule_set.each_selector(options) do |selectors, declarations, specificity|
177
+ yield selectors, declarations, specificity
178
+ end
179
+ end
180
+ end
181
+
182
+ # Output all CSS rules as a single stylesheet.
183
+ def to_s(media_types = :all)
184
+ out = ''
185
+ each_selector(media_types) do |selectors, declarations, specificity|
186
+ out << "#{selectors} {\n#{declarations}\n}\n"
187
+ end
188
+ out
189
+ end
190
+
191
+ # A hash of { :media_query => rule_sets }
192
+ def rules_by_media_query
193
+ rules_by_media = {}
194
+ @rules.each do |block|
195
+ block[:media_types].each do |mt|
196
+ unless rules_by_media.has_key?(mt)
197
+ rules_by_media[mt] = []
198
+ end
199
+ rules_by_media[mt] << block[:rules]
200
+ end
201
+ end
202
+
203
+ rules_by_media
204
+ end
205
+
206
+ # Merge declarations with the same selector.
207
+ def compact! # :nodoc:
208
+ compacted = []
209
+
210
+ compacted
211
+ end
212
+
213
+ def parse_block_into_rule_sets!(block, options = {}) # :nodoc:
214
+ current_media_queries = [:all]
215
+ if options[:media_types]
216
+ current_media_queries = options[:media_types].flatten.collect { |mt| CssParser.sanitize_media_query(mt)}
217
+ end
218
+
219
+ in_declarations = 0
220
+ block_depth = 0
221
+
222
+ in_charset = false # @charset is ignored for now
223
+ in_string = false
224
+ in_at_media_rule = false
225
+ in_media_block = false
226
+
227
+ current_selectors = ''
228
+ current_media_query = ''
229
+ current_declarations = ''
230
+
231
+ block.scan(/([\\]?[{}\s"]|(.[^\s"{}\\]*))/).each do |matches|
232
+ token = matches[0]
233
+
234
+ if token =~ /\A"/ # found un-escaped double quote
235
+ in_string = !in_string
236
+ end
237
+
238
+ if in_declarations > 0
239
+ # too deep, malformed declaration block
240
+ if in_declarations > 1
241
+ in_declarations -= 1 if token =~ /\}/
242
+ next
243
+ end
244
+
245
+ if token =~ /\{/
246
+ in_declarations += 1
247
+ next
248
+ end
249
+
250
+ current_declarations += token
251
+
252
+ if token =~ /\}/ and not in_string
253
+ current_declarations.gsub!(/\}[\s]*$/, '')
254
+
255
+ in_declarations -= 1
256
+
257
+ unless current_declarations.strip.empty?
258
+ add_rule!(current_selectors, current_declarations, current_media_queries)
259
+ end
260
+
261
+ current_selectors = ''
262
+ current_declarations = ''
263
+ end
264
+ elsif token =~ /@media/i
265
+ # found '@media', reset current media_types
266
+ in_at_media_rule = true
267
+ media_types = []
268
+ elsif in_at_media_rule
269
+ if token =~ /\{/
270
+ block_depth = block_depth + 1
271
+ in_at_media_rule = false
272
+ in_media_block = true
273
+ current_media_queries << CssParser.sanitize_media_query(current_media_query)
274
+ current_media_query = ''
275
+ elsif token =~ /[,]/
276
+ # new media query begins
277
+ token.gsub!(/[,]/, ' ')
278
+ current_media_query += token.strip + ' '
279
+ current_media_queries << CssParser.sanitize_media_query(current_media_query)
280
+ current_media_query = ''
281
+ else
282
+ current_media_query += token.strip + ' '
283
+ end
284
+ elsif in_charset or token =~ /@charset/i
285
+ # iterate until we are out of the charset declaration
286
+ in_charset = (token =~ /;/ ? false : true)
287
+ else
288
+ if token =~ /\}/ and not in_string
289
+ block_depth = block_depth - 1
290
+
291
+ # reset the current media query scope
292
+ if in_media_block
293
+ current_media_queries = []
294
+ in_media_block = false
295
+ end
296
+ else
297
+ if token =~ /\{/ and not in_string
298
+ current_selectors.gsub!(/^[\s]*/, '')
299
+ current_selectors.gsub!(/[\s]*$/, '')
300
+ in_declarations += 1
301
+ else
302
+ current_selectors += token
303
+ end
304
+ end
305
+ end
306
+ end
307
+
308
+ # check for unclosed braces
309
+ if in_declarations > 0
310
+ add_rule!(current_selectors, current_declarations, current_media_queries)
311
+ end
312
+ end
313
+
314
+ # Load a remote CSS file.
315
+ #
316
+ # You can also pass in file://test.css
317
+ #
318
+ # See add_block! for options.
319
+ #
320
+ # Deprecated: originally accepted three params: `uri`, `base_uri` and `media_types`
321
+ def load_uri!(uri, options = {}, deprecated = nil)
322
+ uri = Addressable::URI.parse(uri) unless uri.respond_to? :scheme
323
+ #base_uri = nil, media_types = :all, options = {}
324
+
325
+ opts = {:base_uri => nil, :media_types => :all}
326
+
327
+ if options.is_a? Hash
328
+ opts.merge!(options)
329
+ else
330
+ opts[:base_uri] = options if options.is_a? String
331
+ opts[:media_types] = deprecated if deprecated
332
+ end
333
+
334
+ if uri.scheme == 'file' or uri.scheme.nil?
335
+ uri.path = File.expand_path(uri.path)
336
+ uri.scheme = 'file'
337
+ end
338
+
339
+ opts[:base_uri] = uri if opts[:base_uri].nil?
340
+
341
+ src, charset = read_remote_file(uri)
342
+ if src
343
+ add_block!(src, opts)
344
+ end
345
+ end
346
+
347
+ # Load a local CSS file.
348
+ def load_file!(file_name, base_dir = nil, media_types = :all)
349
+ file_name = File.expand_path(file_name, base_dir)
350
+ return unless File.readable?(file_name)
351
+ return unless circular_reference_check(file_name)
352
+
353
+ src = IO.read(file_name)
354
+ base_dir = File.dirname(file_name)
355
+
356
+ add_block!(src, {:media_types => media_types, :base_dir => base_dir})
357
+ end
358
+
359
+
360
+
361
+ protected
362
+ # Check that a path hasn't been loaded already
363
+ #
364
+ # Raises a CircularReferenceError exception if io_exceptions are on,
365
+ # otherwise returns true/false.
366
+ def circular_reference_check(path)
367
+ path = path.to_s
368
+ if @loaded_uris.include?(path)
369
+ raise CircularReferenceError, "can't load #{path} more than once" if @options[:io_exceptions]
370
+ return false
371
+ else
372
+ @loaded_uris << path
373
+ return true
374
+ end
375
+ end
376
+
377
+ # Strip comments and clean up blank lines from a block of CSS.
378
+ #
379
+ # Returns a string.
380
+ def cleanup_block(block) # :nodoc:
381
+ # Strip CSS comments
382
+ block.gsub!(STRIP_CSS_COMMENTS_RX, '')
383
+
384
+ # Strip HTML comments - they shouldn't really be in here but
385
+ # some people are just crazy...
386
+ block.gsub!(STRIP_HTML_COMMENTS_RX, '')
387
+
388
+ # Strip lines containing just whitespace
389
+ block.gsub!(/^\s+$/, "")
390
+
391
+ block
392
+ end
393
+
394
+ # Download a file into a string.
395
+ #
396
+ # Returns the file's data and character set in an array.
397
+ #--
398
+ # TODO: add option to fail silently or throw and exception on a 404
399
+ #++
400
+ def read_remote_file(uri) # :nodoc:
401
+ return nil, nil unless circular_reference_check(uri.to_s)
402
+
403
+ src = '', charset = nil
404
+
405
+ begin
406
+ uri = Addressable::URI.parse(uri.to_s)
407
+
408
+ if uri.scheme == 'file'
409
+ # local file
410
+ fh = open(uri.path, 'rb')
411
+ src = fh.read
412
+ fh.close
413
+ else
414
+ # remote file
415
+ if uri.scheme == 'https'
416
+ uri.port = 443 unless uri.port
417
+ http = Net::HTTP.new(uri.host, uri.port)
418
+ http.use_ssl = true
419
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
420
+ else
421
+ http = Net::HTTP.new(uri.host, uri.port)
422
+ end
423
+
424
+ res = http.get(uri.path, {'User-Agent' => USER_AGENT, 'Accept-Encoding' => 'gzip'})
425
+ src = res.body
426
+ charset = fh.respond_to?(:charset) ? fh.charset : 'utf-8'
427
+
428
+ if res.code.to_i >= 400
429
+ raise RemoteFileError if @options[:io_exceptions]
430
+ return '', nil
431
+ end
432
+
433
+ case res['content-encoding']
434
+ when 'gzip'
435
+ io = Zlib::GzipReader.new(StringIO.new(res.body))
436
+ src = io.read
437
+ when 'deflate'
438
+ io = Zlib::Inflate.new
439
+ src = io.inflate(res.body)
440
+ end
441
+ end
442
+
443
+ if charset
444
+ if String.method_defined?(:encode)
445
+ src.encode!('UTF-8', charset)
446
+ else
447
+ ic = Iconv.new('UTF-8//IGNORE', charset)
448
+ src = ic.iconv(src)
449
+ end
450
+ end
451
+ rescue
452
+ raise RemoteFileError if @options[:io_exceptions]
453
+ return nil, nil
454
+ end
455
+
456
+ return src, charset
457
+ end
458
+
459
+ private
460
+ # Save a folded declaration block to the internal cache.
461
+ def save_folded_declaration(block_hash, folded_declaration) # :nodoc:
462
+ @folded_declaration_cache[block_hash] = folded_declaration
463
+ end
464
+
465
+ # Retrieve a folded declaration block from the internal cache.
466
+ def get_folded_declaration(block_hash) # :nodoc:
467
+ return @folded_declaration_cache[block_hash] ||= nil
468
+ end
469
+
470
+ def reset! # :nodoc:
471
+ @folded_declaration_cache = {}
472
+ @css_source = ''
473
+ @css_rules = []
474
+ @css_warnings = []
475
+ end
476
+ end
477
+ end