kajabi-css_parser 1.2.7

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.
@@ -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