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
data/lib/hexp/builder.rb
ADDED
@@ -0,0 +1,256 @@
|
|
1
|
+
module Hexp
|
2
|
+
# Build Hexps using the builder pattern
|
3
|
+
#
|
4
|
+
class Builder < BasicObject
|
5
|
+
include ::Hexp
|
6
|
+
|
7
|
+
# def inspect
|
8
|
+
# ::Kernel.puts ::Kernel.caller ; ::Kernel.exit
|
9
|
+
# end
|
10
|
+
|
11
|
+
# Construct a new builder, and start building
|
12
|
+
#
|
13
|
+
# The recommended way to call this is through `Hexp.build`.
|
14
|
+
#
|
15
|
+
# @param tag [Symbol] The tag of the outermost element (optional)
|
16
|
+
# @param args [Array<Hash|String>] Extra arguments, a String for a text
|
17
|
+
# node, a Hash for attributes
|
18
|
+
# @param block [Proc] The block containing builder directives, can be with
|
19
|
+
# or without an argument.
|
20
|
+
#
|
21
|
+
# @api private
|
22
|
+
#
|
23
|
+
def initialize(tag = nil, *args, &block)
|
24
|
+
@stack = []
|
25
|
+
if tag
|
26
|
+
tag!(tag, *args, &block)
|
27
|
+
else
|
28
|
+
_process(&block) if block
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Add a tag (HTML element)
|
33
|
+
#
|
34
|
+
# Typically this is called implicitly through method missing, but in case of
|
35
|
+
# name clashes or dynamically generated tags you can call this directly.
|
36
|
+
#
|
37
|
+
# @example
|
38
|
+
# hexp = Hexp.build :div do
|
39
|
+
# tag!(:p, "Oh the code, such sweet joy it brings")
|
40
|
+
# end
|
41
|
+
# hexp.to_html #=> "<div><p>Oh the code, such sweet joy it brings</p></div>"
|
42
|
+
#
|
43
|
+
# @param tag [Symbol] The tag name, like 'div' or 'head'
|
44
|
+
# @param args [Array<Hash|String>] A hash of attributes, or a string to use
|
45
|
+
# inside the tag, or both. Multiple occurences of each can be
|
46
|
+
# specified
|
47
|
+
# @param block [Proc] Builder directives for the contents of the tag
|
48
|
+
# @return [NilClass]
|
49
|
+
#
|
50
|
+
# @api public
|
51
|
+
#
|
52
|
+
def tag!(tag, *args, &block)
|
53
|
+
text, attributes = nil, {}
|
54
|
+
args.each do |arg|
|
55
|
+
case arg
|
56
|
+
when ::Hash
|
57
|
+
attributes.merge!(arg)
|
58
|
+
when ::String
|
59
|
+
text ||= ''
|
60
|
+
text << arg
|
61
|
+
end
|
62
|
+
end
|
63
|
+
@stack << [tag, attributes, text ? [text] : []]
|
64
|
+
if block
|
65
|
+
_process(&block)
|
66
|
+
end
|
67
|
+
if @stack.length > 1
|
68
|
+
node = @stack.pop
|
69
|
+
@stack.last[2] << node
|
70
|
+
NodeBuilder.new(node, self)
|
71
|
+
else
|
72
|
+
NodeBuilder.new(@stack.last, self)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
alias method_missing tag!
|
77
|
+
|
78
|
+
# Add a text node to the tree
|
79
|
+
#
|
80
|
+
# @example
|
81
|
+
# hexp = Hexp.build do
|
82
|
+
# span do
|
83
|
+
# text! 'Not all who wander are lost'
|
84
|
+
# end
|
85
|
+
# end
|
86
|
+
#
|
87
|
+
# @param text [String] the text to add
|
88
|
+
# @return [Hexp::Builder] self
|
89
|
+
# @api public
|
90
|
+
#
|
91
|
+
def text!(text)
|
92
|
+
_raise_if_empty! "Hexp::Builder needs a root element to add text elements to"
|
93
|
+
@stack.last[2] << text.to_s
|
94
|
+
self
|
95
|
+
end
|
96
|
+
|
97
|
+
# Add Hexp objects to the current tag
|
98
|
+
#
|
99
|
+
# Any Hexp::Node or other object implementing to_hexp can be added with
|
100
|
+
# this operator. Multiple objects can be specified in one call.
|
101
|
+
#
|
102
|
+
# Nokogiri and Builder allow inserting of strings containing HTML through
|
103
|
+
# this operator. Since this would violate the core philosophy of Hexp, and
|
104
|
+
# open the door for XSS vulnerabilities, we do not support that usage.
|
105
|
+
#
|
106
|
+
# If you really want to insert HTML that is already in serialized form,
|
107
|
+
# consider parsing it to Hexps first
|
108
|
+
#
|
109
|
+
# @example
|
110
|
+
# widget = H[:button, "click me!"]
|
111
|
+
# node = Hexp.build :div do |h|
|
112
|
+
# h << widget
|
113
|
+
# end
|
114
|
+
# node.to_html #=> <div><button>click me!</button></div>
|
115
|
+
#
|
116
|
+
# @params args [Array<:to_hexp>] Hexpable objects to add to the current tag
|
117
|
+
# @return [Hexp::Builder]
|
118
|
+
#
|
119
|
+
# @api public
|
120
|
+
#
|
121
|
+
def <<(*args)
|
122
|
+
args.each do |arg|
|
123
|
+
if arg.respond_to?(:to_hexp)
|
124
|
+
@stack.last[2] << arg
|
125
|
+
self
|
126
|
+
else
|
127
|
+
::Kernel.raise ::Hexp::FormatError, "Inserting literal HTML into a builder with << is deliberately not supported by Hexp"
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# Implement the standard Hexp coercion protocol
|
133
|
+
#
|
134
|
+
# By implementing this a Builder is interchangeable for a regular node, so
|
135
|
+
# you can use it inside other nodes transparently. But you can call this
|
136
|
+
# method if you really, really just want the plain {Hexp::Node}
|
137
|
+
#
|
138
|
+
# @example
|
139
|
+
# Hexp.build { div { text! 'hello' } }.to_hexp # => H[:div, ["hello"]]
|
140
|
+
#
|
141
|
+
# @return [Hexp::Node]
|
142
|
+
# @api public
|
143
|
+
#
|
144
|
+
def to_hexp
|
145
|
+
_raise_if_empty!
|
146
|
+
::Hexp::Node[*@stack.last]
|
147
|
+
end
|
148
|
+
|
149
|
+
# Call the block, with a specific value of 'self'
|
150
|
+
#
|
151
|
+
# If the block takes an argument, then we pass ourselves (the builder) to
|
152
|
+
# the block, and call it as a closure. This way 'self' refers to the calling
|
153
|
+
# object, and it can reference its own methods and ivars.
|
154
|
+
#
|
155
|
+
# If the block does not take an argument, then we evaluate it in the context
|
156
|
+
# of ourselves (the builder), so unqualified method calls are seen as
|
157
|
+
# builder calls.
|
158
|
+
#
|
159
|
+
# @param block [Proc]
|
160
|
+
# @return [NilClass]
|
161
|
+
# @api private
|
162
|
+
#
|
163
|
+
def _process(&block)
|
164
|
+
if block.arity == 1
|
165
|
+
block.call(self)
|
166
|
+
else
|
167
|
+
self.instance_eval(&block)
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
# Allow setting HTML classes through method calls
|
172
|
+
#
|
173
|
+
# @example
|
174
|
+
# Hexp.build do
|
175
|
+
# div.miraculous.wondrous do
|
176
|
+
# hr
|
177
|
+
# end
|
178
|
+
# end
|
179
|
+
#
|
180
|
+
# @api private
|
181
|
+
#
|
182
|
+
class NodeBuilder
|
183
|
+
# Create new NodeBuilder
|
184
|
+
#
|
185
|
+
# @param node [Array] (tag, attrs, children) triplet
|
186
|
+
# @param builder [Hexp::Builder] The parent builder to delegate back
|
187
|
+
# @api private
|
188
|
+
#
|
189
|
+
def initialize(node, builder)
|
190
|
+
@node, @builder = node, builder
|
191
|
+
end
|
192
|
+
|
193
|
+
# Used for specifying CSS class names
|
194
|
+
#
|
195
|
+
# @example
|
196
|
+
# Hexp.build { div.strong.warn }.to_hexp
|
197
|
+
# # => H[:div, class: 'strong warn']
|
198
|
+
#
|
199
|
+
# @param sym [Symbol] the class to add
|
200
|
+
# @return [Hexp::Builder::NodeBuilder] self
|
201
|
+
# @api public
|
202
|
+
#
|
203
|
+
def method_missing(sym, &block)
|
204
|
+
attrs = @node[1]
|
205
|
+
@node[1] = attrs.merge class: [attrs[:class], sym.to_s].compact.join(' ')
|
206
|
+
@builder._process &block if block
|
207
|
+
self
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
# Return a debugging representation
|
212
|
+
#
|
213
|
+
# Hexp is intended for HTML, so it shouldn't be a problem that this is an
|
214
|
+
# actual method. It really helps for debugging or when playing around in
|
215
|
+
# irb. If you really want an `<inspect>` tag, use `tag!(:inspect)`.
|
216
|
+
#
|
217
|
+
# @example
|
218
|
+
# p Hexp.build { div }
|
219
|
+
#
|
220
|
+
# @return [String]
|
221
|
+
# @api public
|
222
|
+
#
|
223
|
+
def inspect
|
224
|
+
"#<Hexp::Builder #{@stack.empty? ? '[]' :to_hexp.inspect}>"
|
225
|
+
end
|
226
|
+
|
227
|
+
# Gratefully borrowed from Builder.
|
228
|
+
# I'd like to benchmark this singleton class based version vs
|
229
|
+
# adding the methods to the class directly, before putting this in.
|
230
|
+
#
|
231
|
+
# @param sym [Symbol] Name of the method to define
|
232
|
+
# @api private
|
233
|
+
#
|
234
|
+
# def _cache_method_call(sym)
|
235
|
+
# class << self; self; end.class_eval do
|
236
|
+
# unless method_defined?(sym)
|
237
|
+
# define_method(sym) do |*args, &block|
|
238
|
+
# tag!(sym, *args, &block)
|
239
|
+
# end
|
240
|
+
# end
|
241
|
+
# end
|
242
|
+
# end
|
243
|
+
|
244
|
+
private
|
245
|
+
|
246
|
+
# Raise an exception if nothing has been built yet
|
247
|
+
#
|
248
|
+
# @param text [String] The error message
|
249
|
+
# @raises {Hexp::FormatError}
|
250
|
+
# @api private
|
251
|
+
#
|
252
|
+
def _raise_if_empty!(text = 'Hexp::Builder is lacking a root element.')
|
253
|
+
::Kernel.raise ::Hexp::FormatError, text if @stack.empty?
|
254
|
+
end
|
255
|
+
end
|
256
|
+
end
|
@@ -0,0 +1,205 @@
|
|
1
|
+
module Hexp
|
2
|
+
module CssSelector
|
3
|
+
# Common behavior for parse tree nodes based on a list of members
|
4
|
+
#
|
5
|
+
module Members
|
6
|
+
include Equalizer.new(:members)
|
7
|
+
|
8
|
+
extend Forwardable
|
9
|
+
def_delegator :@members, :empty?
|
10
|
+
|
11
|
+
# Member nodes
|
12
|
+
#
|
13
|
+
attr_reader :members
|
14
|
+
|
15
|
+
# Shared initializer for parse tree nodes with children (members)
|
16
|
+
#
|
17
|
+
def initialize(members)
|
18
|
+
@members = Hexp.deep_freeze(members)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Create a class level collection constructor
|
22
|
+
#
|
23
|
+
# @example
|
24
|
+
# CommaSequence[member1, member2]
|
25
|
+
#
|
26
|
+
# @param klass [Class]
|
27
|
+
# @api private
|
28
|
+
#
|
29
|
+
def self.included(klass)
|
30
|
+
def klass.[](*members)
|
31
|
+
new(members)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Return a debugging representation
|
36
|
+
#
|
37
|
+
# @return [String]
|
38
|
+
# @api private
|
39
|
+
#
|
40
|
+
def inspect
|
41
|
+
"#{self.class.name.split('::').last}[#{self.members.map(&:inspect).join(', ')}]"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Common behavior for parse tree elements that have a name
|
46
|
+
#
|
47
|
+
module Named
|
48
|
+
include Equalizer.new(:name)
|
49
|
+
attr_reader :name
|
50
|
+
|
51
|
+
def initialize(name)
|
52
|
+
@name = name.freeze
|
53
|
+
end
|
54
|
+
|
55
|
+
def inspect
|
56
|
+
"<#{self.class.name.split('::').last} name=#{name}>"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Top level parse tree node of a CSS selector
|
61
|
+
#
|
62
|
+
# Contains a number of {Sequence} objects
|
63
|
+
#
|
64
|
+
# For example : `span .big, a'
|
65
|
+
#
|
66
|
+
class CommaSequence
|
67
|
+
include Members
|
68
|
+
|
69
|
+
# def inspect
|
70
|
+
# members.map(&:inspect).join(', ')
|
71
|
+
# end
|
72
|
+
|
73
|
+
# Does any sequence in this comma sequence fully match the given element
|
74
|
+
#
|
75
|
+
# This method does not recurse, it only checks if any of the sequences in
|
76
|
+
# this CommaSequence with a length of one can fully match the given
|
77
|
+
# element.
|
78
|
+
#
|
79
|
+
# @param element [Hexp::Node]
|
80
|
+
# @return [Boolean]
|
81
|
+
#
|
82
|
+
def matches?(element)
|
83
|
+
members.any? do |sequence|
|
84
|
+
sequence.members.count == 1 &&
|
85
|
+
sequence.head_matches?(element)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# A single CSS sequence like 'div span .foo'
|
91
|
+
#
|
92
|
+
class Sequence
|
93
|
+
|
94
|
+
include Members
|
95
|
+
|
96
|
+
def head_matches?(element)
|
97
|
+
members.first.matches?(element)
|
98
|
+
end
|
99
|
+
|
100
|
+
def drop_head
|
101
|
+
self.class.new(members.drop(1))
|
102
|
+
end
|
103
|
+
|
104
|
+
# def inspect
|
105
|
+
# members.map(&:inspect).join(' ')
|
106
|
+
# end
|
107
|
+
end
|
108
|
+
|
109
|
+
# A CSS sequence that relates to a single element, like 'div.caption:first'
|
110
|
+
#
|
111
|
+
class SimpleSequence
|
112
|
+
include Members
|
113
|
+
|
114
|
+
def matches?(element)
|
115
|
+
members.all? do |simple|
|
116
|
+
simple.matches?(element)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
# def inspect
|
121
|
+
# members.map(&:inspect).join
|
122
|
+
# end
|
123
|
+
end
|
124
|
+
|
125
|
+
# A CSS element declaration, like 'div'
|
126
|
+
class Element
|
127
|
+
include Named
|
128
|
+
|
129
|
+
def matches?(element)
|
130
|
+
element.tag.to_s == name
|
131
|
+
end
|
132
|
+
|
133
|
+
# def inspect
|
134
|
+
# name
|
135
|
+
# end
|
136
|
+
end
|
137
|
+
|
138
|
+
# A CSS class declaration, like '.foo'
|
139
|
+
#
|
140
|
+
class Class
|
141
|
+
include Named
|
142
|
+
|
143
|
+
def matches?(element)
|
144
|
+
element.class?(name)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
# A CSS id declaration, like '#section-14'
|
149
|
+
#
|
150
|
+
class Id
|
151
|
+
include Named
|
152
|
+
|
153
|
+
def matches?(element)
|
154
|
+
element.attr('id') == name
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
# An attribute selector, like [href^="http://"]
|
159
|
+
#
|
160
|
+
class Attribute
|
161
|
+
include Equalizer.new(:name, :namespace, :operator, :value, :flags)
|
162
|
+
attr_reader :name, :namespace, :operator, :value, :flags
|
163
|
+
|
164
|
+
def initialize(name, namespace, operator, value, flags)
|
165
|
+
@name = name.freeze
|
166
|
+
@namespace = namespace.freeze
|
167
|
+
@operator = operator.freeze
|
168
|
+
@value = value.freeze
|
169
|
+
@flag = flags.freeze
|
170
|
+
end
|
171
|
+
|
172
|
+
def inspect
|
173
|
+
"<#{self.class.name.split('::').last} name=#{name} namespace=#{namespace.inspect} operator=#{operator.inspect} value=#{value.inspect} flags=#{flags.inspect}>"
|
174
|
+
end
|
175
|
+
|
176
|
+
def matches?(element)
|
177
|
+
return false unless element[name]
|
178
|
+
attribute = element[name]
|
179
|
+
|
180
|
+
case operator
|
181
|
+
# CSS 2
|
182
|
+
when nil
|
183
|
+
true
|
184
|
+
when '=' # exact match
|
185
|
+
attribute == value
|
186
|
+
when '~=' # space separated list contains
|
187
|
+
attribute.split(' ').include?(value)
|
188
|
+
when '|=' # equal to, or starts with followed by a dash
|
189
|
+
attribute =~ /\A#{Regexp.escape(value)}(-|\z)/
|
190
|
+
|
191
|
+
# CSS 3
|
192
|
+
when '^=' # starts with
|
193
|
+
attribute.index(value) == 0
|
194
|
+
when '$=' # ends with
|
195
|
+
attribute =~ /#{Regexp.escape(value)}\z/
|
196
|
+
when '*=' # contains
|
197
|
+
!!(attribute =~ /#{Regexp.escape(value)}/)
|
198
|
+
|
199
|
+
else
|
200
|
+
raise "Unknown operator : #{operator}"
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|