hexp 0.0.1 → 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.
- data/.travis.yml +12 -3
- data/Changelog.md +9 -0
- data/Gemfile +3 -5
- data/Gemfile.devtools +20 -18
- data/Gemfile.lock +97 -84
- data/Rakefile +16 -0
- data/config/flay.yml +2 -2
- data/config/flog.yml +1 -1
- data/config/reek.yml +42 -18
- data/config/rubocop.yml +31 -0
- data/config/yardstick.yml +39 -1
- data/examples/from_nokogiri.rb +77 -0
- data/examples/selector_rewriter_chaining.rb +14 -0
- data/examples/todo.rb +138 -0
- data/examples/widget.rb +64 -0
- data/hexp.gemspec +8 -3
- data/lib/hexp.rb +103 -2
- data/lib/hexp/builder.rb +256 -0
- data/lib/hexp/css_selector.rb +205 -0
- data/lib/hexp/css_selector/parser.rb +74 -0
- data/lib/hexp/css_selector/sass_parser.rb +22 -0
- data/lib/hexp/dom.rb +0 -2
- data/lib/hexp/dsl.rb +27 -0
- data/lib/hexp/errors.rb +21 -0
- data/lib/hexp/h.rb +5 -2
- data/lib/hexp/list.rb +67 -9
- data/lib/hexp/node.rb +197 -41
- data/lib/hexp/node/attributes.rb +176 -0
- data/lib/hexp/node/children.rb +44 -0
- data/lib/hexp/node/css_selection.rb +73 -0
- data/lib/hexp/node/domize.rb +52 -6
- data/lib/hexp/node/normalize.rb +19 -9
- data/lib/hexp/node/pp.rb +32 -0
- data/lib/hexp/node/rewriter.rb +52 -0
- data/lib/hexp/node/selector.rb +59 -0
- data/lib/hexp/nokogiri/equality.rb +61 -0
- data/lib/hexp/nokogiri/reader.rb +27 -0
- data/lib/hexp/sass/selector_parser.rb +4 -0
- data/lib/hexp/text_node.rb +129 -9
- data/lib/hexp/version.rb +1 -1
- data/notes +34 -0
- data/spec/shared_helper.rb +6 -0
- data/spec/spec_helper.rb +2 -6
- data/spec/unit/hexp/builder_spec.rb +101 -0
- data/spec/unit/hexp/css_selector/attribute_spec.rb +137 -0
- data/spec/unit/hexp/css_selector/class_spec.rb +15 -0
- data/spec/unit/hexp/css_selector/comma_sequence_spec.rb +20 -0
- data/spec/unit/hexp/css_selector/element_spec.rb +11 -0
- data/spec/unit/hexp/css_selector/parser_spec.rb +51 -0
- data/spec/unit/hexp/css_selector/simple_sequence_spec.rb +48 -0
- data/spec/unit/hexp/dsl_spec.rb +55 -0
- data/spec/unit/hexp/h_spec.rb +38 -0
- data/spec/unit/hexp/list_spec.rb +19 -0
- data/spec/unit/hexp/node/attr_spec.rb +55 -0
- data/spec/unit/hexp/node/attributes_spec.rb +125 -0
- data/spec/unit/hexp/node/children_spec.rb +33 -0
- data/spec/unit/hexp/node/class_spec.rb +37 -0
- data/spec/unit/hexp/node/css_selection_spec.rb +86 -0
- data/spec/unit/hexp/node/normalize_spec.rb +12 -6
- data/spec/unit/hexp/node/rewrite_spec.rb +67 -30
- data/spec/unit/hexp/node/selector_spec.rb +78 -0
- data/spec/unit/hexp/node/text_spec.rb +7 -0
- data/spec/unit/hexp/node/to_dom_spec.rb +1 -1
- data/spec/unit/hexp/nokogiri/reader_spec.rb +8 -0
- data/spec/unit/hexp/parse_spec.rb +23 -0
- data/spec/unit/hexp/text_node_spec.rb +25 -0
- data/spec/unit/hexp_spec.rb +33 -0
- metadata +129 -16
- data/lib/hexp/format_error.rb +0 -8
@@ -0,0 +1,176 @@
|
|
1
|
+
module Hexp
|
2
|
+
class Node
|
3
|
+
# Node API methods that deal with attributes
|
4
|
+
#
|
5
|
+
module Attributes
|
6
|
+
# Attribute getter/setter
|
7
|
+
#
|
8
|
+
# When called with one argument : return the attribute value with that name.
|
9
|
+
# When called with two arguments : return a new Node with the attribute set.
|
10
|
+
# When the second argument is nil : return a new Node with the attribute unset.
|
11
|
+
#
|
12
|
+
# @example
|
13
|
+
# H[:p, class: 'hello'].attr('class') # => "hello"
|
14
|
+
# H[:p, class: 'hello'].attr('id', 'para1') # => H[:p, {"class"=>"hello", "id"=>"para1"}]
|
15
|
+
# H[:p, class: 'hello'].attr('class', nil) # => H[:p]
|
16
|
+
#
|
17
|
+
# @return [String|Hexp::Node]
|
18
|
+
# @api private
|
19
|
+
#
|
20
|
+
def attr(*args)
|
21
|
+
arity = args.count
|
22
|
+
attr_name = args[0].to_s
|
23
|
+
|
24
|
+
case arity
|
25
|
+
when 1
|
26
|
+
attributes[attr_name]
|
27
|
+
when 2
|
28
|
+
set_attr(*args)
|
29
|
+
else
|
30
|
+
raise ArgumentError, "wrong number of arguments(#{arity} for 1..2)"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# Is an attribute present
|
35
|
+
#
|
36
|
+
# This will also return true if the attribute is present but empty.
|
37
|
+
#
|
38
|
+
# @example
|
39
|
+
# H[:option].has_attr?('selected') #=> false
|
40
|
+
#
|
41
|
+
# @param name [String|Symbol] the name of the attribute
|
42
|
+
# @return [Boolean]
|
43
|
+
# @api public
|
44
|
+
#
|
45
|
+
def has_attr?(name)
|
46
|
+
attributes.has_key? name.to_s
|
47
|
+
end
|
48
|
+
|
49
|
+
# Check for the presence of a class
|
50
|
+
#
|
51
|
+
# @example
|
52
|
+
# H[:span, class: "banner strong"].class?("strong") #=> true
|
53
|
+
#
|
54
|
+
# @param klass [String] the name of the class to check for
|
55
|
+
# @return [Boolean] true if the class is present, false otherwise
|
56
|
+
# @api public
|
57
|
+
#
|
58
|
+
def class?(klass)
|
59
|
+
attr('class') && attr('class').split(' ').include?(klass.to_s)
|
60
|
+
end
|
61
|
+
|
62
|
+
# Add a CSS class to the element
|
63
|
+
#
|
64
|
+
# @example
|
65
|
+
# H[:div].add_class('foo') #=> H[:div, class: 'foo']
|
66
|
+
#
|
67
|
+
# @param klass [#to_s] The class to add
|
68
|
+
# @return [Hexp::Node]
|
69
|
+
# @api public
|
70
|
+
#
|
71
|
+
def add_class(klass)
|
72
|
+
attr('class', [attr('class'), klass].compact.join(' '))
|
73
|
+
end
|
74
|
+
|
75
|
+
# The CSS classes of this element as an array
|
76
|
+
#
|
77
|
+
# Convenience method so you don't have to split the class list yourself.
|
78
|
+
#
|
79
|
+
# @return [Array<String>]
|
80
|
+
# @api public
|
81
|
+
#
|
82
|
+
def class_list
|
83
|
+
@class_list ||= (attr('class') || '').split(' ').freeze
|
84
|
+
end
|
85
|
+
|
86
|
+
# Remove a CSS class from this element
|
87
|
+
#
|
88
|
+
# If the resulting class list is empty, the class attribute will be
|
89
|
+
# removed. If the class is present several times all instances will
|
90
|
+
# be removed. If it's not present at all, the class list will be
|
91
|
+
# unmodified.
|
92
|
+
#
|
93
|
+
# Calling this on a node with a class attribute that is equal to an
|
94
|
+
# empty string will result in the class attribute being removed.
|
95
|
+
#
|
96
|
+
# @param klass [#to_s] The class to be removed
|
97
|
+
# @return [Hexp::Node] A node that is identical to this one, but with
|
98
|
+
# the given class removed
|
99
|
+
# @api public
|
100
|
+
#
|
101
|
+
def remove_class(klass)
|
102
|
+
return self unless has_attr?('class')
|
103
|
+
new_list = class_list - [klass.to_s]
|
104
|
+
return remove_attr('class') if new_list.empty?
|
105
|
+
attr('class', new_list.join(' '))
|
106
|
+
end
|
107
|
+
|
108
|
+
# Set or override multiple attributes using a hash syntax
|
109
|
+
#
|
110
|
+
# @param attrs[Hash]
|
111
|
+
# @return [Hexp::Node]
|
112
|
+
# @api public
|
113
|
+
#
|
114
|
+
def set_attrs(attrs)
|
115
|
+
H[
|
116
|
+
self.tag,
|
117
|
+
self.attributes.merge(Hash[*attrs.flat_map{|k,v| [k.to_s, v]}]),
|
118
|
+
self.children
|
119
|
+
]
|
120
|
+
end
|
121
|
+
alias :% :set_attrs
|
122
|
+
alias :add_attributes :set_attrs
|
123
|
+
|
124
|
+
# Remove an attribute by name
|
125
|
+
#
|
126
|
+
# @param name [#to_s] The attribute to be removed
|
127
|
+
# @return [Hexp::Node] a new node with the attribute removed
|
128
|
+
# @api public
|
129
|
+
#
|
130
|
+
def remove_attr(name)
|
131
|
+
H[
|
132
|
+
self.tag,
|
133
|
+
self.attributes.reject {|key,_| key == name.to_s},
|
134
|
+
self.children
|
135
|
+
]
|
136
|
+
end
|
137
|
+
|
138
|
+
# Attribute accessor
|
139
|
+
#
|
140
|
+
# @param attribute_name [#to_s] The name of the attribute
|
141
|
+
# @return [String] The value of the attribute
|
142
|
+
# @api public
|
143
|
+
#
|
144
|
+
def [](attr_name)
|
145
|
+
self.attributes[attr_name.to_s]
|
146
|
+
end
|
147
|
+
|
148
|
+
# Merge attributes into this Hexp
|
149
|
+
#
|
150
|
+
# Class attributes are treated special : the class lists are merged, rather
|
151
|
+
# than being overwritten. See {set_attrs} for a more basic version.
|
152
|
+
#
|
153
|
+
# This method is analoguous with {Hash#merge}. As argument it can take a
|
154
|
+
# Hash, or another Hexp element, in which case that element's attributes
|
155
|
+
# are used.
|
156
|
+
#
|
157
|
+
# @param node_or_hash [#to_hexp|Hash]
|
158
|
+
# @return [Hexp::Node]
|
159
|
+
# @api public
|
160
|
+
#
|
161
|
+
def merge_attrs(node_or_hash)
|
162
|
+
hash = node_or_hash.respond_to?(:to_hexp) ?
|
163
|
+
node_or_hash.to_hexp.attributes : node_or_hash
|
164
|
+
result = self
|
165
|
+
hash.each do |key,value|
|
166
|
+
result = if key.to_s == 'class'
|
167
|
+
result.add_class(value)
|
168
|
+
else
|
169
|
+
result.attr(key, value)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
result
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
module Hexp
|
2
|
+
class Node
|
3
|
+
# Node API methods that deal with child_nodes
|
4
|
+
#
|
5
|
+
module Children
|
6
|
+
# Is this node an empty node
|
7
|
+
#
|
8
|
+
# H[:p, class: 'foo'].empty? #=> true
|
9
|
+
# H[:p, [H[:span]].empty? #=> false
|
10
|
+
#
|
11
|
+
# @return [Boolean] true if this node has no children
|
12
|
+
# @api public
|
13
|
+
#
|
14
|
+
def empty?
|
15
|
+
children.empty?
|
16
|
+
end
|
17
|
+
|
18
|
+
def add_child(child)
|
19
|
+
H[
|
20
|
+
self.tag,
|
21
|
+
self.attributes,
|
22
|
+
self.children + [child]
|
23
|
+
]
|
24
|
+
end
|
25
|
+
alias :add :add_child
|
26
|
+
alias :<< :add_child
|
27
|
+
|
28
|
+
def text
|
29
|
+
children.map do |node|
|
30
|
+
node.text? ? node : node.text
|
31
|
+
end.join
|
32
|
+
end
|
33
|
+
|
34
|
+
def set_children(new_children)
|
35
|
+
H[tag, attributes, new_children]
|
36
|
+
end
|
37
|
+
|
38
|
+
def map_children(&blk)
|
39
|
+
return to_enum(:map_children) unless block_given?
|
40
|
+
H[tag, attributes, children.map(&blk)]
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
module Hexp
|
2
|
+
class Node
|
3
|
+
# Select nodes using CSS selectors
|
4
|
+
#
|
5
|
+
class CssSelection < Selector
|
6
|
+
def initialize(node, css_selector)
|
7
|
+
@node, @css_selector = node, css_selector
|
8
|
+
end
|
9
|
+
|
10
|
+
def inspect
|
11
|
+
"#<#{self.class} @node=#{@node.inspect} @css_selector=#{@css_selector.inspect} matches=#{node_matches?}>"
|
12
|
+
end
|
13
|
+
|
14
|
+
def each(&block)
|
15
|
+
return to_enum(:each) unless block_given?
|
16
|
+
|
17
|
+
@node.children.each do |child|
|
18
|
+
next_selection_for(child).each(&block)
|
19
|
+
end
|
20
|
+
yield @node if node_matches?
|
21
|
+
end
|
22
|
+
|
23
|
+
def rewrite(&block)
|
24
|
+
return @node if @node.text?
|
25
|
+
|
26
|
+
new_node = H[
|
27
|
+
@node.tag,
|
28
|
+
@node.attributes,
|
29
|
+
@node.children.flat_map do |child|
|
30
|
+
next_selection_for(child).rewrite(&block)
|
31
|
+
end
|
32
|
+
]
|
33
|
+
node_matches? ? block.call(new_node) : new_node
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def comma_sequence
|
39
|
+
@comma_sequence ||= coerce_to_comma_sequence(@css_selector)
|
40
|
+
end
|
41
|
+
|
42
|
+
def coerce_to_comma_sequence(css_selector)
|
43
|
+
return css_selector if css_selector.is_a? CssSelector::CommaSequence
|
44
|
+
CssSelector::Parser.call(@css_selector)
|
45
|
+
end
|
46
|
+
|
47
|
+
def node_matches?
|
48
|
+
comma_sequence.matches?(@node)
|
49
|
+
end
|
50
|
+
|
51
|
+
# returns a new commasequence with the parts removed that have been consumed by matching
|
52
|
+
# against this node. If no part matches, return nil
|
53
|
+
def next_comma_sequence
|
54
|
+
@next_comma_sequence ||= CssSelector::CommaSequence.new(consume_matching_heads)
|
55
|
+
end
|
56
|
+
|
57
|
+
def next_selection_for(child)
|
58
|
+
self.class.new(child, next_comma_sequence)
|
59
|
+
end
|
60
|
+
|
61
|
+
def consume_matching_heads
|
62
|
+
comma_sequence.members.flat_map do |sequence|
|
63
|
+
if sequence.head_matches? @node
|
64
|
+
[sequence, sequence.drop_head]
|
65
|
+
else
|
66
|
+
[sequence]
|
67
|
+
end
|
68
|
+
end.reject(&:empty?)
|
69
|
+
end
|
70
|
+
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
data/lib/hexp/node/domize.rb
CHANGED
@@ -2,22 +2,54 @@ module Hexp
|
|
2
2
|
class Node
|
3
3
|
# Turn nodes into DOM objects
|
4
4
|
class Domize
|
5
|
+
DEFAULT_OPTIONS = {
|
6
|
+
:include_doctype => true
|
7
|
+
}.freeze
|
8
|
+
|
9
|
+
# The resulting DOM Document
|
10
|
+
#
|
11
|
+
# @return [Nokogiri::HTML::Document]
|
12
|
+
# @api private
|
13
|
+
#
|
5
14
|
attr_reader :dom
|
6
15
|
|
7
|
-
|
8
|
-
|
9
|
-
|
16
|
+
# Instanitiate a Domizer
|
17
|
+
#
|
18
|
+
# @param hexp [Hexp::Node]
|
19
|
+
# @param options [Hash] :include_doctype defaults to true
|
20
|
+
# @api private
|
21
|
+
#
|
22
|
+
def initialize(hexp, options = {})
|
23
|
+
@dom = Hexp::DOM
|
24
|
+
@raw = hexp
|
25
|
+
@options = DEFAULT_OPTIONS.merge(options).freeze
|
10
26
|
end
|
11
27
|
|
28
|
+
# Turn the hexp into a DOM
|
29
|
+
#
|
30
|
+
# @return [Nokogiri::HTML::Document]
|
31
|
+
# @api private
|
32
|
+
#
|
12
33
|
def call
|
13
|
-
dom::Document.new
|
14
|
-
|
15
|
-
|
34
|
+
@doc = dom::Document.new
|
35
|
+
@root = domize(@raw)
|
36
|
+
@doc << @root
|
37
|
+
|
38
|
+
if @options[:include_doctype]
|
39
|
+
@doc
|
40
|
+
else
|
41
|
+
@root
|
16
42
|
end
|
17
43
|
end
|
18
44
|
|
19
45
|
private
|
20
46
|
|
47
|
+
# Turn a Hexp::Node into a Document
|
48
|
+
#
|
49
|
+
# @param hexp [Hexp::Node]
|
50
|
+
# @return [Nokogiri::HTML::Document]
|
51
|
+
# @api private
|
52
|
+
#
|
21
53
|
def domize(hexp)
|
22
54
|
dom::Node.new(hexp.tag.to_s, @doc).tap do |node|
|
23
55
|
set_attributes(node, hexp.attributes)
|
@@ -25,12 +57,26 @@ module Hexp
|
|
25
57
|
end
|
26
58
|
end
|
27
59
|
|
60
|
+
# Set attributes on a DOM node
|
61
|
+
#
|
62
|
+
# @param node [Nokogiri::XML::Element]
|
63
|
+
# @param attributes [Hash]
|
64
|
+
# @return [void]
|
65
|
+
# @api private
|
66
|
+
#
|
28
67
|
def set_attributes(node, attributes)
|
29
68
|
attributes.each do |key,value|
|
30
69
|
node[key] = value
|
31
70
|
end
|
32
71
|
end
|
33
72
|
|
73
|
+
# Set children on the DOM node
|
74
|
+
#
|
75
|
+
# @param node [Nokogiri::XML::Element]
|
76
|
+
# @param children [Hexp::List]
|
77
|
+
# @return [void]
|
78
|
+
# @api private
|
79
|
+
#
|
34
80
|
def set_children(node, children)
|
35
81
|
children.each do |child|
|
36
82
|
if child.instance_of?(TextNode)
|
data/lib/hexp/node/normalize.rb
CHANGED
@@ -40,6 +40,12 @@ module Hexp
|
|
40
40
|
{}
|
41
41
|
end
|
42
42
|
|
43
|
+
# Returns the attributes hash with key and value converted to strings
|
44
|
+
#
|
45
|
+
# @return [Hash]
|
46
|
+
#
|
47
|
+
# @api private
|
48
|
+
#
|
43
49
|
def normalized_attributes
|
44
50
|
Hash[*
|
45
51
|
attributes.flat_map do |key, value|
|
@@ -55,10 +61,14 @@ module Hexp
|
|
55
61
|
# @api private
|
56
62
|
#
|
57
63
|
def children
|
58
|
-
@raw
|
59
|
-
|
64
|
+
last = @raw.last
|
65
|
+
if last.respond_to? :to_ary
|
66
|
+
last.to_ary
|
67
|
+
elsif @raw.count < 2 || last.instance_of?(Hash)
|
68
|
+
[]
|
69
|
+
else
|
70
|
+
[last]
|
60
71
|
end
|
61
|
-
[]
|
62
72
|
end
|
63
73
|
|
64
74
|
# Normalize the third element of a hexp node, the list of children
|
@@ -75,14 +85,14 @@ module Hexp
|
|
75
85
|
child
|
76
86
|
when String, TextNode
|
77
87
|
Hexp::TextNode.new(child)
|
88
|
+
when ->(ch) { ch.respond_to? :to_hexp }
|
89
|
+
response = child.to_hexp
|
90
|
+
raise FormatError, "to_hexp must return a Hexp::Node, got #{response.inspect}" unless response.instance_of?(Hexp::Node)
|
91
|
+
response
|
78
92
|
when Array
|
79
|
-
Hexp::Node[*child]
|
93
|
+
Hexp::Node[*child.map(&:freeze)]
|
80
94
|
else
|
81
|
-
|
82
|
-
response = child.to_hexp
|
83
|
-
raise FormatError, "to_hexp must return a Hexp::Node, got #{response.inspect}" unless response.instance_of?(Hexp::Node)
|
84
|
-
response
|
85
|
-
end
|
95
|
+
raise FormatError, "Invalid value in Hexp literal : #{child.inspect} (#{child.class}) does not implement #to_hexp ; #{children.inspect}"
|
86
96
|
end
|
87
97
|
end
|
88
98
|
]
|
data/lib/hexp/node/pp.rb
CHANGED
@@ -2,10 +2,20 @@ module Hexp
|
|
2
2
|
class Node
|
3
3
|
# Pretty-print a node and its contents
|
4
4
|
class PP
|
5
|
+
# Create a new pretty-printer
|
6
|
+
#
|
7
|
+
# @param node [Hexp::Node] The node to represent
|
8
|
+
# @api private
|
9
|
+
#
|
5
10
|
def initialize(node)
|
6
11
|
@node = node
|
7
12
|
end
|
8
13
|
|
14
|
+
# Perform the pretty-printing
|
15
|
+
#
|
16
|
+
# @return [String] The pp output
|
17
|
+
# @api private
|
18
|
+
#
|
9
19
|
def call
|
10
20
|
[
|
11
21
|
@node.class.inspect_name,
|
@@ -14,22 +24,44 @@ module Hexp
|
|
14
24
|
].join
|
15
25
|
end
|
16
26
|
|
27
|
+
# Format the node tag
|
28
|
+
#
|
29
|
+
# @return [String]
|
30
|
+
# @api private
|
31
|
+
#
|
17
32
|
def pp_tag
|
18
33
|
"[#{@node.tag.inspect}"
|
19
34
|
end
|
20
35
|
|
36
|
+
# Format the node attributes
|
37
|
+
#
|
38
|
+
# @return [String]
|
39
|
+
# @api private
|
40
|
+
#
|
21
41
|
def pp_attributes
|
22
42
|
attrs = @node.attributes
|
23
43
|
return '' if attrs.empty?
|
24
44
|
', ' + attrs.inspect
|
25
45
|
end
|
26
46
|
|
47
|
+
# Format the node children
|
48
|
+
#
|
49
|
+
# @return [String]
|
50
|
+
# @api private
|
51
|
+
#
|
27
52
|
def pp_children
|
28
53
|
children = @node.children
|
29
54
|
return ']' if children.empty?
|
30
55
|
", [\n#{ children.map(&:pp).join(",\n") }]]"
|
31
56
|
end
|
32
57
|
|
58
|
+
# Indent a multiline string with a number of spaces
|
59
|
+
#
|
60
|
+
# @param string [String] The string to indent
|
61
|
+
# @param indent [Integer] The number of spaces to use for indentation
|
62
|
+
# @return [String]
|
63
|
+
# @api private
|
64
|
+
#
|
33
65
|
def self.indent(string, indent = 2)
|
34
66
|
string.lines.map {|line| " "*indent + line}.join
|
35
67
|
end
|