silk_layout 0.1.0 → 0.2.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.
@@ -5,10 +5,13 @@ module SilkLayout
5
5
  class Selector
6
6
  def initialize(raw)
7
7
  @raw = raw.to_s.strip
8
- @steps = parse_steps(@raw)
8
+ @valid = !@raw.empty?
9
+ @steps = @valid ? parse_steps(@raw) : []
10
+ @valid &&= @steps.any?
9
11
  end
10
12
 
11
13
  def match?(node)
14
+ return false unless @valid
12
15
  return false unless node.respond_to?(:element?)
13
16
  return false unless node.element?
14
17
  return false if node.tag.nil?
@@ -29,6 +32,10 @@ module SilkLayout
29
32
  when :descendant
30
33
  current = find_ancestor(current.parent, simple)
31
34
  return false unless current
35
+ when :adjacent
36
+ current = previous_element_sibling(current)
37
+ return false unless current
38
+ return false unless step_match?(simple, current)
32
39
  else
33
40
  return false
34
41
  end
@@ -45,10 +52,10 @@ module SilkLayout
45
52
  elements = 0
46
53
 
47
54
  @steps.each do |step|
48
- simple = step[:simple]
49
- ids += 1 if simple[:id]
50
- classes += simple[:classes].length
51
- elements += 1 if simple[:tag]
55
+ simple_ids, simple_classes, simple_elements = simple_specificity(step[:simple])
56
+ ids += simple_ids
57
+ classes += simple_classes
58
+ elements += simple_elements
52
59
  end
53
60
 
54
61
  [ids, classes, elements]
@@ -56,52 +63,103 @@ module SilkLayout
56
63
 
57
64
  private
58
65
 
66
+ IDENT = /[a-zA-Z][a-zA-Z0-9_-]*/
67
+ NAME = /[a-zA-Z0-9_-]+/
68
+
59
69
  def parse_steps(raw)
60
70
  tokens = tokenize(raw)
61
71
  parts = []
62
72
  pending_combinator = nil
73
+ expecting_simple = true
63
74
 
64
75
  tokens.each do |tok|
65
- if tok == ">"
66
- pending_combinator = :child
76
+ if tok == " "
77
+ pending_combinator ||= :descendant unless expecting_simple || parts.empty?
67
78
  next
68
79
  end
69
80
 
70
- if tok == " "
71
- pending_combinator ||= :descendant
81
+ if tok == ">" || tok == "+"
82
+ return invalid_steps if parts.empty? || expecting_simple
83
+
84
+ pending_combinator = (tok == ">") ? :child : :adjacent
85
+ expecting_simple = true
72
86
  next
73
87
  end
74
88
 
75
- parts << {simple: parse_simple(tok), combinator: pending_combinator}
89
+ simple = parse_simple(tok)
90
+ return invalid_steps unless simple
91
+
92
+ parts << {
93
+ simple: simple,
94
+ combinator: parts.empty? ? nil : (pending_combinator || :descendant)
95
+ }
76
96
  pending_combinator = nil
97
+ expecting_simple = false
77
98
  end
78
99
 
79
- # Default combinator between left/right parts is descendant.
80
- # Combinator is stored on the RIGHT step to indicate how it relates to the LEFT.
81
- parts.each_with_index do |part, idx|
82
- next if idx == 0
83
- part[:combinator] ||= :descendant
84
- end
100
+ return invalid_steps if expecting_simple && parts.any?
85
101
 
86
102
  parts
87
103
  end
88
104
 
89
105
  def tokenize(raw)
90
- raw = raw.strip
91
106
  tokens = []
92
107
  buf = +""
93
108
  in_space = false
109
+ quote = nil
110
+ bracket_depth = 0
111
+ paren_depth = 0
112
+
113
+ raw.strip.each_char do |ch|
114
+ if quote
115
+ buf << ch
116
+ quote = nil if ch == quote
117
+ next
118
+ end
94
119
 
95
- raw.each_char do |ch|
96
- if ch == ">"
120
+ if ch == '"' || ch == "'"
121
+ quote = ch
122
+ buf << ch
123
+ next
124
+ end
125
+
126
+ if ch == "["
127
+ bracket_depth += 1
128
+ buf << ch
129
+ next
130
+ end
131
+
132
+ if ch == "]"
133
+ bracket_depth -= 1
134
+ return invalid_steps if bracket_depth.negative?
135
+
136
+ buf << ch
137
+ next
138
+ end
139
+
140
+ if ch == "("
141
+ paren_depth += 1
142
+ buf << ch
143
+ next
144
+ end
145
+
146
+ if ch == ")"
147
+ paren_depth -= 1
148
+ return invalid_steps if paren_depth.negative?
149
+
150
+ buf << ch
151
+ next
152
+ end
153
+
154
+ if (ch == ">" || ch == "+") && bracket_depth.zero? && paren_depth.zero?
97
155
  tokens << buf unless buf.empty?
98
156
  buf = +""
99
- tokens << ">"
157
+ tokens << ch
100
158
  in_space = false
101
159
  next
102
160
  end
103
161
 
104
- if ch.match?(/\s/)
162
+ if ch.match?(/\s/) && bracket_depth.zero? && paren_depth.zero?
105
163
  tokens << buf unless buf.empty?
106
164
  buf = +""
107
165
  tokens << " " unless in_space
@@ -113,31 +171,85 @@ module SilkLayout
113
171
  buf << ch
114
172
  end
115
173
 
174
+ return invalid_steps if quote || !bracket_depth.zero? || !paren_depth.zero?
175
+
116
176
  tokens << buf unless buf.empty?
117
177
  tokens.reject(&:empty?)
118
178
  end
119
179
 
120
- def parse_simple(token)
180
+ def parse_simple(token, allow_not: true)
121
181
  tag = nil
122
182
  id = nil
123
183
  classes = []
184
+ attributes = []
185
+ pseudo_classes = []
186
+ negations = []
187
+ universal = false
124
188
 
125
189
  rest = token.to_s
126
- if rest.match?(/\A[a-zA-Z][a-zA-Z0-9_-]*/)
127
- m = rest.match(/\A([a-zA-Z][a-zA-Z0-9_-]*)/)
128
- tag = m[1]
190
+ if rest.start_with?("*")
191
+ universal = true
192
+ rest = rest[1..].to_s
193
+ elsif (m = rest.match(/\A(#{IDENT.source})/))
194
+ tag = m[1].downcase
129
195
  rest = rest[m[1].length..]
130
196
  end
131
197
 
132
- rest.scan(/([#.])([a-zA-Z0-9_-]+)/) do |kind, value|
133
- if kind == "#"
134
- id = value
198
+ until rest.empty?
199
+ if (m = rest.match(/\A#(#{NAME.source})/))
200
+ id = m[1]
201
+ rest = rest[m[0].length..].to_s
202
+ elsif (m = rest.match(/\A\.(#{NAME.source})/))
203
+ classes << m[1]
204
+ rest = rest[m[0].length..].to_s
205
+ elsif rest.start_with?("[")
206
+ closing = closing_index(rest, "]")
207
+ return invalid_simple unless closing
208
+
209
+ attribute = parse_attribute(rest[1...closing])
210
+ return invalid_simple unless attribute
211
+
212
+ attributes << attribute
213
+ rest = rest[(closing + 1)..].to_s
214
+ elsif rest.start_with?(":first-child")
215
+ pseudo_classes << :first_child
216
+ rest = rest[12..].to_s
217
+ elsif rest.start_with?(":last-child")
218
+ pseudo_classes << :last_child
219
+ rest = rest[11..].to_s
220
+ elsif rest.start_with?(":not(")
221
+ return invalid_simple unless allow_not
222
+
223
+ closing = closing_index(rest, ")")
224
+ return invalid_simple unless closing
225
+
226
+ inner = rest[5...closing].to_s.strip
227
+ inner_tokens = tokenize(inner)
228
+ return invalid_simple unless @valid && inner_tokens.length == 1
229
+
230
+ negation = parse_simple(inner_tokens.first, allow_not: false)
231
+ return invalid_simple unless negation
232
+
233
+ negations << negation
234
+ rest = rest[(closing + 1)..].to_s
135
235
  else
136
- classes << value
236
+ return invalid_simple
137
237
  end
138
238
  end
139
239
 
140
- {tag: tag, id: id, classes: classes}
240
+ if tag.nil? && id.nil? && classes.empty? && attributes.empty? && pseudo_classes.empty? && negations.empty? && !universal
241
+ return invalid_simple
242
+ end
243
+
244
+ {
245
+ tag: tag,
246
+ id: id,
247
+ classes: classes,
248
+ attributes: attributes,
249
+ pseudo_classes: pseudo_classes,
250
+ negations: negations,
251
+ universal: universal
252
+ }
141
253
  end
142
254
 
143
255
  def step_match?(simple, node)
@@ -149,6 +261,26 @@ module SilkLayout
149
261
  return false unless simple[:classes].all? { |c| node_classes.include?(c) }
150
262
  end
151
263
 
264
+ simple[:attributes].each do |attribute|
265
+ return false unless node.attributes.key?(attribute[:name])
266
+ return false if attribute.key?(:value) && node.attributes[attribute[:name]] != attribute[:value]
267
+ end
268
+
269
+ simple[:pseudo_classes].each do |pseudo_class|
270
+ case pseudo_class
271
+ when :first_child
272
+ return false unless first_element_child?(node)
273
+ when :last_child
274
+ return false unless last_element_child?(node)
275
+ else
276
+ return false
277
+ end
278
+ end
279
+
280
+ simple[:negations].each do |negation|
281
+ return false if step_match?(negation, node)
282
+ end
283
+
152
284
  true
153
285
  end
154
286
 
@@ -160,6 +292,90 @@ module SilkLayout
160
292
  end
161
293
  nil
162
294
  end
295
+
296
+ def simple_specificity(simple)
297
+ ids = simple[:id] ? 1 : 0
298
+ classes = simple[:classes].length + simple[:attributes].length + simple[:pseudo_classes].length
299
+ elements = simple[:tag] ? 1 : 0
300
+
301
+ simple[:negations].each do |negation|
302
+ negation_ids, negation_classes, negation_elements = simple_specificity(negation)
303
+ ids += negation_ids
304
+ classes += negation_classes
305
+ elements += negation_elements
306
+ end
307
+
308
+ [ids, classes, elements]
309
+ end
310
+
311
+ def parse_attribute(raw)
312
+ m = raw.to_s.strip.match(/\A(#{IDENT.source})(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=\]]+)))?\z/)
313
+ return nil unless m
314
+
315
+ attribute = {name: m[1].downcase}
316
+ value = m[2] || m[3] || m[4]
317
+ attribute[:value] = value unless value.nil?
318
+ attribute
319
+ end
320
+
321
+ def closing_index(raw, character)
322
+ quote = nil
323
+
324
+ raw.each_char.with_index do |ch, index|
325
+ next if index.zero?
326
+
327
+ if quote
328
+ quote = nil if ch == quote
329
+ next
330
+ end
331
+
332
+ if ch == '"' || ch == "'"
333
+ quote = ch
334
+ next
335
+ end
336
+
337
+ return index if ch == character
338
+ end
339
+
340
+ nil
341
+ end
342
+
343
+ def first_element_child?(node)
344
+ element_siblings(node).first == node
345
+ end
346
+
347
+ def last_element_child?(node)
348
+ element_siblings(node).last == node
349
+ end
350
+
351
+ def element_siblings(node)
352
+ node.parent&.children&.select { |child| child.respond_to?(:element?) && child.element? } || []
353
+ end
354
+
355
+ def previous_element_sibling(node)
356
+ siblings = node.parent&.children
357
+ return nil unless siblings
358
+
359
+ index = siblings.index(node)
360
+ return nil unless index
361
+
362
+ (index - 1).downto(0) do |candidate_index|
363
+ candidate = siblings[candidate_index]
364
+ return candidate if candidate.respond_to?(:element?) && candidate.element?
365
+ end
366
+
367
+ nil
368
+ end
369
+
370
+ def invalid_steps
371
+ @valid = false
372
+ []
373
+ end
374
+
375
+ def invalid_simple
376
+ @valid = false
377
+ nil
378
+ end
163
379
  end
164
380
  end
165
381
  end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module SilkLayout
6
+ module CSS
7
+ module Values
8
+ NUMBER = /[-+]?(?:\d+(?:\.\d+)?|\.\d+)/
9
+ LENGTH = /\A(?<number>#{NUMBER.source})(?<unit>px|%)?\z/i
10
+ CALC = /\Acalc\((?<expression>.*)\)\z/i
11
+
12
+ NAMED_LENGTHS = {
13
+ "thin" => 1,
14
+ "medium" => 3,
15
+ "thick" => 5
16
+ }.freeze
17
+
18
+ Term = Struct.new(:value, :unit)
19
+
20
+ class Length
21
+ attr_reader :type, :value, :unit, :terms
22
+
23
+ def self.parse(value)
24
+ raw = value.to_s.strip
25
+ normalized = raw.downcase
26
+ return new(:auto) if normalized.empty? || normalized == "auto" || normalized == "none"
27
+
28
+ named = NAMED_LENGTHS[normalized]
29
+ return new(:length, value: named.to_f, unit: "px") if named
30
+
31
+ calc_match = raw.match(CALC)
32
+ if calc_match
33
+ parsed = parse_calc(calc_match[:expression])
34
+ return parsed if parsed
35
+
36
+ return new(:length, value: raw.to_f, unit: "px")
37
+ end
38
+
39
+ match = raw.match(LENGTH)
40
+ return new(:length, value: match[:number].to_f, unit: (match[:unit] || "px").downcase) if match
41
+
42
+ new(:length, value: raw.to_f, unit: "px")
43
+ end
44
+
45
+ def self.parse_calc(expression)
46
+ scanner = StringScanner.new(expression)
47
+ terms = []
48
+ sign = 1
49
+
50
+ until scanner.eos?
51
+ scanner.skip(/\s+/)
52
+
53
+ operator = scanner.scan(/[+-]/)
54
+ sign = (operator == "-") ? -1 : 1 if operator
55
+ scanner.skip(/\s+/)
56
+
57
+ token = scanner.scan(/(?:\d+(?:\.\d+)?|\.\d+)(?:px|%)?/i)
58
+ return nil unless token
59
+
60
+ length = parse(token)
61
+ return nil unless length.type == :length
62
+
63
+ terms << Term.new(length.value * sign, length.unit)
64
+ scanner.skip(/\s+/)
65
+
66
+ return nil unless scanner.eos? || scanner.peek(1).match?(/[+-]/)
67
+ end
68
+
69
+ return nil if terms.empty?
70
+
71
+ new(:calc, terms: terms)
72
+ end
73
+
74
+ def initialize(type, value: 0, unit: nil, terms: [])
75
+ @type = type
76
+ @value = value
77
+ @unit = unit
78
+ @terms = terms
79
+ end
80
+
81
+ def resolve(reference: nil, default: 0)
82
+ case type
83
+ when :auto
84
+ default
85
+ when :length
86
+ return default if unit == "%" && reference.nil?
87
+
88
+ (unit == "%") ? reference * value / 100.0 : value
89
+ when :calc
90
+ return default if terms.any? { |term| term.unit == "%" } && reference.nil?
91
+
92
+ terms.sum do |term|
93
+ (term.unit == "%") ? reference * term.value / 100.0 : term.value
94
+ end
95
+ else
96
+ default
97
+ end
98
+ end
99
+
100
+ def reference_relative?
101
+ return true if type == :length && unit == "%"
102
+ return true if type == :calc && terms.any? { |term| term.unit == "%" }
103
+
104
+ false
105
+ end
106
+ end
107
+
108
+ module_function
109
+
110
+ def length(value)
111
+ value.is_a?(Length) ? value : Length.parse(value)
112
+ end
113
+
114
+ def resolve_length(value, reference: nil, default: 0)
115
+ length(value).resolve(reference: reference, default: default)
116
+ end
117
+
118
+ def reference_relative?(value)
119
+ length(value).reference_relative?
120
+ end
121
+
122
+ def split_tokens(value)
123
+ tokens = []
124
+ current = +""
125
+ depth = 0
126
+
127
+ value.to_s.each_char do |char|
128
+ case char
129
+ when "("
130
+ depth += 1
131
+ current << char
132
+ when ")"
133
+ depth -= 1 if depth.positive?
134
+ current << char
135
+ when /\s/
136
+ if depth.zero?
137
+ tokens << current unless current.empty?
138
+ current = +""
139
+ else
140
+ current << char
141
+ end
142
+ else
143
+ current << char
144
+ end
145
+ end
146
+
147
+ tokens << current unless current.empty?
148
+ tokens
149
+ end
150
+
151
+ def expanded_edges(value)
152
+ tokens = split_tokens(value)
153
+
154
+ case tokens.length
155
+ when 0
156
+ {top: nil, right: nil, bottom: nil, left: nil}
157
+ when 1
158
+ {top: tokens[0], right: tokens[0], bottom: tokens[0], left: tokens[0]}
159
+ when 2
160
+ {top: tokens[0], right: tokens[1], bottom: tokens[0], left: tokens[1]}
161
+ when 3
162
+ {top: tokens[0], right: tokens[1], bottom: tokens[2], left: tokens[1]}
163
+ else
164
+ {top: tokens[0], right: tokens[1], bottom: tokens[2], left: tokens[3]}
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -6,6 +6,7 @@ module SilkLayout
6
6
  attr_reader :tag, :attributes, :text
7
7
  attr_accessor :children, :parent
8
8
  attr_accessor :computed_style
9
+ attr_accessor :resolved_source_url
9
10
 
10
11
  def initialize(tag:, attributes:, children:, text: nil, parent: nil)
11
12
  @tag = tag
@@ -13,7 +13,10 @@ module SilkLayout
13
13
  base_uri = base_uri_for(document, url)
14
14
  stylesheets = extract_stylesheets!(document, base_uri)
15
15
 
16
- [Node.from_nokogiri(document.root), stylesheets]
16
+ root = Node.from_nokogiri(document.root)
17
+ resolve_resource_urls!(root, base_uri)
18
+
19
+ [root, stylesheets]
17
20
  end
18
21
 
19
22
  def self.extract_stylesheets!(document, base_uri)
@@ -75,7 +78,7 @@ module SilkLayout
75
78
  dir = p.to_s
76
79
  dir = "#{dir}/" unless dir.end_with?("/")
77
80
 
78
- URI::Generic.build(scheme: "file", path: dir)
81
+ URI::Generic.build(scheme: "file", path: URI::RFC2396_PARSER.escape(dir, /[^A-Za-z0-9\-._~\/]/))
79
82
  end
80
83
 
81
84
  def self.fetch_stylesheet(href, base_uri, cache)
@@ -107,7 +110,18 @@ module SilkLayout
107
110
 
108
111
  URI.join(base_uri.to_s, href)
109
112
  rescue URI::InvalidURIError
110
- URI.join(base_uri.to_s, URI::DEFAULT_PARSER.escape(href))
113
+ URI.join(base_uri.to_s, URI::RFC2396_PARSER.escape(href))
114
+ end
115
+
116
+ def self.resolve_resource_urls!(node, base_uri)
117
+ return unless node
118
+
119
+ if node.element? && node.tag == "img"
120
+ src = node.attributes["src"].to_s.strip
121
+ node.resolved_source_url = normalize_href(src, base_uri) unless src.empty?
122
+ end
123
+
124
+ node.children.each { |child| resolve_resource_urls!(child, base_uri) }
111
125
  end
112
126
 
113
127
  def self.fetch_http(uri)