htx 0.0.6 → 0.0.9
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +10 -15
- data/VERSION +1 -1
- data/lib/htx/template.rb +291 -183
- data/lib/htx/text_parser.rb +233 -0
- data/lib/htx/version.rb +1 -1
- data/lib/htx.rb +9 -5
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 395620b3af20b4da17e4413632418481ee28d6d0fe6b46427af240475087efce
|
4
|
+
data.tar.gz: bc15b61294daaf218b3a3d5a106de7c9279a7d9e8cb7ebd01002fcd618491b1f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 83805b10f30d1846a187b458579a0748339a3956a4123226b7b9ebdb6dcff6cae3dbcd94913c1bbab2c91186ef3f46642f30a4ed9c3f30b072b3efe2f2ceb1ca
|
7
|
+
data.tar.gz: 26e48419a9a719f1b8b582ef9e525ffcc37affdcb279f0cba95c6fe4cd2108cc6ec66e9ba4d3216a0ef02aad506d56c4743b059916ba78409f0dc672a760a1fc
|
data/README.md
CHANGED
@@ -20,29 +20,24 @@ gem install htx
|
|
20
20
|
|
21
21
|
## Usage
|
22
22
|
|
23
|
-
To compile an HTX template, pass a name (conventionally the path of the template file) and template
|
24
|
-
strings to the `HTX.compile` method:
|
23
|
+
To compile an HTX template, pass a name (conventionally the path of the template file) and template content
|
24
|
+
as strings to the `HTX.compile` method (all other arguments are optional):
|
25
25
|
|
26
26
|
```ruby
|
27
|
-
path = '/
|
27
|
+
path = '/components/crew.htx'
|
28
28
|
template = File.read(File.join('some/asset/dir', path))
|
29
29
|
|
30
30
|
HTX.compile(path, template)
|
31
31
|
|
32
|
-
#
|
32
|
+
# window['/components/crew.htx'] = function(htx) {
|
33
|
+
# // ...
|
34
|
+
# }
|
35
|
+
|
33
36
|
HTX.compile(path, template, assign_to: 'myTemplates')
|
34
37
|
|
35
|
-
#
|
36
|
-
#
|
37
|
-
#
|
38
|
-
# ...
|
39
|
-
# }
|
40
|
-
#
|
41
|
-
# If `assign_to` is specified:
|
42
|
-
#
|
43
|
-
# myTemplates['/components/people.htx'] = function(htx) {
|
44
|
-
# // ...
|
45
|
-
# }
|
38
|
+
# myTemplates['/components/crew.htx'] = function(htx) {
|
39
|
+
# // ...
|
40
|
+
# }
|
46
41
|
```
|
47
42
|
|
48
43
|
## Contributing
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.0.
|
1
|
+
0.0.9
|
data/lib/htx/template.rb
CHANGED
@@ -4,42 +4,36 @@ require('nokogiri')
|
|
4
4
|
|
5
5
|
module HTX
|
6
6
|
class Template
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
FLAG_BITS
|
7
|
+
ELEMENT = 1 << 0
|
8
|
+
CHILDLESS = 1 << 1
|
9
|
+
XMLNS = 1 << 2
|
10
|
+
FLAG_BITS = 3
|
11
11
|
|
12
12
|
INDENT_DEFAULT = ' '
|
13
|
-
|
13
|
+
CONTENT_TAG = 'htx-content'
|
14
14
|
DYNAMIC_KEY_ATTR = 'htx-key'
|
15
|
-
|
16
15
|
DEFAULT_XMLNS = {
|
17
16
|
'math' => 'http://www.w3.org/1998/Math/MathML',
|
18
17
|
'svg' => 'http://www.w3.org/2000/svg',
|
19
18
|
}.freeze
|
20
19
|
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
INTERPOLATION = /\$\\?{([^}]*})?/.freeze
|
35
|
-
HTML_ENTITY = /&([a-zA-Z]+|#\d+|x[0-9a-fA-F]+);/.freeze
|
36
|
-
NON_CONTROL_STATEMENT = /#{INTERPOLATION}|(#{HTML_ENTITY})/.freeze
|
37
|
-
CONTROL_STATEMENT = /[{}();]/.freeze
|
38
|
-
CLOSE_STATEMENT = /;?\s*htx\.close\((\d*)\);?(\s*)\z/.freeze
|
20
|
+
INDENT_VALID = /^( +|\t+)$/.freeze
|
21
|
+
INDENT_GUESS = /^( +|\t+)(?=\S)/.freeze
|
22
|
+
INDENT_REGEX = /\n(?=[^\n])/.freeze
|
23
|
+
|
24
|
+
NO_SEMICOLON_BEGIN = /\A\s*[\n;}]/.freeze
|
25
|
+
NO_SEMICOLON_END = /(\A|[\n;{}][^\S\n]*)\z/.freeze
|
26
|
+
|
27
|
+
NEWLINE_BEGIN = /\A\s*\n/.freeze
|
28
|
+
NEWLINE_END = /\n[^\S\n]*\z/.freeze
|
29
|
+
NEWLINE_END_OPTIONAL = /\n?[^\S\n]*\z/.freeze
|
30
|
+
|
31
|
+
WHITESPACE_BEGIN = /\A\s/.freeze
|
32
|
+
NON_WHITESPACE = /\S/.freeze
|
39
33
|
|
40
34
|
##
|
41
35
|
# Returns false. In the near future when support for the <:> tag has been dropped (in favor of
|
42
|
-
# <htx-
|
36
|
+
# <htx-content>), will return true if Nokogiri's HTML5 parser is available. To use it now, monkey patch
|
43
37
|
# this method to return true.
|
44
38
|
#
|
45
39
|
def self.html5_parser?
|
@@ -51,15 +45,14 @@ module HTX
|
|
51
45
|
# otherwise.
|
52
46
|
#
|
53
47
|
def self.nokogiri_parser
|
54
|
-
html5_parser? ? Nokogiri::HTML5
|
48
|
+
html5_parser? ? Nokogiri::HTML5 : Nokogiri::HTML
|
55
49
|
end
|
56
50
|
|
57
51
|
##
|
58
|
-
# Creates a new
|
52
|
+
# Creates a new instance.
|
59
53
|
#
|
60
|
-
# * +name+ -
|
61
|
-
#
|
62
|
-
# * +content+ - Template content string.
|
54
|
+
# * +name+ - Template name. Conventionally the path of the template file.
|
55
|
+
# * +content+ - Template content.
|
63
56
|
#
|
64
57
|
def initialize(name, content)
|
65
58
|
@name = name
|
@@ -69,107 +62,196 @@ module HTX
|
|
69
62
|
##
|
70
63
|
# Compiles the HTX template.
|
71
64
|
#
|
72
|
-
# * +
|
73
|
-
#
|
74
|
-
#
|
75
|
-
#
|
76
|
-
#
|
77
|
-
def compile(
|
78
|
-
|
79
|
-
root_nodes = doc.children.select { |n| n.element? || (n.text? && n.text.strip != '') }
|
80
|
-
|
81
|
-
if (text_node = root_nodes.find(&:text?))
|
82
|
-
raise(MalformedTemplateError.new('text nodes are not allowed at root level', @name, text_node))
|
83
|
-
elsif root_nodes.size == 0
|
84
|
-
raise(MalformedTemplateError.new('a root node is required', @name))
|
85
|
-
elsif root_nodes.size > 1
|
86
|
-
raise(MalformedTemplateError.new("root node already defined on line #{root_nodes[0].line}", @name,
|
87
|
-
root_nodes[1]))
|
88
|
-
end
|
89
|
-
|
90
|
-
@compiled = ''.dup
|
91
|
-
@static_key = 0
|
92
|
-
|
65
|
+
# * +assign_to+ - JavaScript object to assign the template function to (default: +window+).
|
66
|
+
# * +indent+ - DEPRECATED. Indentation amount (number) or string (must be only spaces or tabs but not
|
67
|
+
# both) to use for indentation of compiled output (default: indentation of first indented line of
|
68
|
+
# uncompiled template).
|
69
|
+
#
|
70
|
+
def compile(assign_to: nil, indent: (indent_omitted = true; nil))
|
71
|
+
@assign_to = assign_to || 'window'
|
93
72
|
@indent =
|
94
73
|
if indent.kind_of?(Numeric)
|
95
74
|
' ' * indent
|
96
|
-
elsif indent.
|
97
|
-
raise("Invalid indent value #{indent.inspect}: only spaces and tabs are allowed")
|
75
|
+
elsif indent && !indent.match?(INDENT_VALID)
|
76
|
+
raise("Invalid indent value #{indent.inspect}: only spaces and tabs (but not both) are allowed")
|
98
77
|
else
|
99
78
|
indent || @content[INDENT_GUESS] || INDENT_DEFAULT
|
100
79
|
end
|
101
80
|
|
81
|
+
warn('The indent: option for HTX template compilation is deprecated.') unless indent_omitted
|
82
|
+
|
83
|
+
@static_key = 0
|
84
|
+
@close_count = 0
|
85
|
+
@whitespace_buff = nil
|
86
|
+
@statement_buff = +''
|
87
|
+
@compiled = +''
|
88
|
+
|
89
|
+
doc = self.class.nokogiri_parser.fragment(@content)
|
90
|
+
preprocess(doc)
|
102
91
|
process(doc)
|
103
|
-
@compiled.rstrip!
|
104
92
|
|
105
|
-
|
106
|
-
#{assign_to}['#{@name}'] = function(htx) {
|
107
|
-
#{@indent}#{@compiled}
|
108
|
-
}
|
109
|
-
EOS
|
93
|
+
@compiled
|
110
94
|
end
|
111
95
|
|
112
96
|
private
|
113
97
|
|
98
|
+
##
|
99
|
+
# Removes comment nodes and merges any adjoining text nodes that result from such removals.
|
100
|
+
#
|
101
|
+
# * +node+ - Nokogiri node to preprocess.
|
102
|
+
#
|
103
|
+
def preprocess(node)
|
104
|
+
if node.text?
|
105
|
+
if node.parent&.fragment? && node.blank?
|
106
|
+
node.remove
|
107
|
+
elsif (prev_node = node.previous)&.text?
|
108
|
+
prev_node.content += node.content
|
109
|
+
node.remove
|
110
|
+
end
|
111
|
+
elsif node.comment?
|
112
|
+
if node.previous&.text? && node.next&.text? && node.next.content.match?(NEWLINE_BEGIN)
|
113
|
+
content = node.previous.content.sub!(NEWLINE_END_OPTIONAL, '')
|
114
|
+
content.empty? ? node.previous.remove : node.previous.content = content
|
115
|
+
end
|
116
|
+
|
117
|
+
node.remove
|
118
|
+
end
|
119
|
+
|
120
|
+
node.children.each do |child|
|
121
|
+
preprocess(child)
|
122
|
+
end
|
123
|
+
|
124
|
+
if node.fragment?
|
125
|
+
children = node.children
|
126
|
+
root, root2 = children[0..1]
|
127
|
+
|
128
|
+
if (child = children.find(&:text?))
|
129
|
+
raise(MalformedTemplateError.new('text nodes are not allowed at root level', @name, child))
|
130
|
+
elsif !root
|
131
|
+
raise(MalformedTemplateError.new('a root node is required', @name))
|
132
|
+
elsif root2
|
133
|
+
raise(MalformedTemplateError.new("root node already defined on line #{root.line}", @name, root2))
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
114
138
|
##
|
115
139
|
# Processes a DOM node's descendents.
|
116
140
|
#
|
117
|
-
# * +
|
141
|
+
# * +node+ - Nokogiri node to process.
|
118
142
|
#
|
119
|
-
def process(
|
120
|
-
|
121
|
-
|
143
|
+
def process(node, xmlns: false)
|
144
|
+
if node.fragment?
|
145
|
+
process_fragment_node(node)
|
146
|
+
elsif node.element?
|
147
|
+
process_element_node(node, xmlns: xmlns)
|
148
|
+
elsif node.text?
|
149
|
+
process_text_node(node)
|
150
|
+
else
|
151
|
+
raise(MalformedTemplateError.new("unrecognized node type #{node.class}", @name, node))
|
152
|
+
end
|
153
|
+
end
|
122
154
|
|
123
|
-
|
155
|
+
##
|
156
|
+
# Processes a document fragment node.
|
157
|
+
#
|
158
|
+
# * +node+ - Nokogiri node to process.
|
159
|
+
#
|
160
|
+
def process_fragment_node(node)
|
161
|
+
append("#{@assign_to}['#{@name}'] = function(htx) {")
|
162
|
+
@whitespace_buff = "\n"
|
124
163
|
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
'for identical functionality.')
|
129
|
-
end
|
164
|
+
node.children.each do |child|
|
165
|
+
process(child)
|
166
|
+
end
|
130
167
|
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
end
|
168
|
+
append("\n}\n",)
|
169
|
+
flush
|
170
|
+
end
|
135
171
|
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
172
|
+
##
|
173
|
+
# Processes an element node.
|
174
|
+
#
|
175
|
+
# * +node+ - Nokogiri node to process.
|
176
|
+
# * +xmlns+ - True if node is the descendant of a node with an xmlns attribute.
|
177
|
+
#
|
178
|
+
def process_element_node(node, xmlns: false)
|
179
|
+
children = node.children
|
180
|
+
childless = children.empty? || (children.size == 1 && self.class.formatting_node?(children.first))
|
181
|
+
dynamic_key = self.class.attribute_value(node.attr(DYNAMIC_KEY_ATTR))
|
182
|
+
attributes = self.class.process_attributes(node)
|
183
|
+
xmlns ||= !!self.class.namespace(node)
|
184
|
+
|
185
|
+
if self.class.htx_content_node?(node)
|
186
|
+
if node.name != CONTENT_TAG
|
187
|
+
warn("#{@name}:#{node.line}: The <#{node.name}> tag has been deprecated. Use <#{CONTENT_TAG}> "\
|
188
|
+
"for identical functionality.")
|
189
|
+
end
|
190
|
+
|
191
|
+
if attributes.size > 0
|
192
|
+
raise(MalformedTemplateError.new("<#{node.name}> tags may not have attributes other than "\
|
193
|
+
"#{DYNAMIC_KEY_ATTR}", @name, node))
|
194
|
+
elsif (child = children.find { |n| !n.text? })
|
195
|
+
raise(MalformedTemplateError.new("<#{node.name}> tags may not contain child tags", @name, child))
|
196
|
+
end
|
197
|
+
|
198
|
+
process_text_node(
|
199
|
+
children.first || Nokogiri::XML::Text.new('', node.document),
|
200
|
+
dynamic_key: dynamic_key,
|
201
|
+
)
|
202
|
+
else
|
203
|
+
append_htx_node(
|
204
|
+
"'#{self.class.tag_name(node.name)}'",
|
205
|
+
*attributes,
|
206
|
+
dynamic_key,
|
207
|
+
ELEMENT | (childless ? CHILDLESS : 0) | (xmlns ? XMLNS : 0),
|
208
|
+
)
|
209
|
+
|
210
|
+
unless childless
|
211
|
+
children.each do |child|
|
212
|
+
process(child, xmlns: xmlns)
|
150
213
|
end
|
214
|
+
|
215
|
+
@close_count += 1
|
216
|
+
end
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
##
|
221
|
+
# Processes a text node.
|
222
|
+
#
|
223
|
+
# * +node+ - Nokogiri node to process.
|
224
|
+
# * +dynamic_key+ - Dynamic key of the parent if it's an <htx-content> node.
|
225
|
+
#
|
226
|
+
def process_text_node(node, dynamic_key: nil)
|
227
|
+
content = node.content
|
228
|
+
|
229
|
+
if node.blank?
|
230
|
+
if !content.include?("\n")
|
231
|
+
append_htx_node("`#{content}`")
|
232
|
+
elsif node.next
|
233
|
+
append(content)
|
151
234
|
else
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
end
|
235
|
+
@whitespace_buff = content[NEWLINE_END]
|
236
|
+
end
|
237
|
+
else
|
238
|
+
htx_content_node = self.class.htx_content_node?(node.parent)
|
239
|
+
parser = TextParser.new(content, statement_allowed: !htx_content_node)
|
240
|
+
parser.parse
|
241
|
+
|
242
|
+
append(parser.leading) unless htx_content_node
|
243
|
+
|
244
|
+
if parser.statement?
|
245
|
+
append(indent(parser.content))
|
246
|
+
elsif parser.raw?
|
247
|
+
append_htx_node(indent(parser.content), dynamic_key)
|
248
|
+
else
|
249
|
+
append_htx_node(parser.content, dynamic_key)
|
250
|
+
end
|
251
|
+
|
252
|
+
unless htx_content_node
|
253
|
+
append(parser.trailing)
|
254
|
+
@whitespace_buff = parser.whitespace_buff
|
173
255
|
end
|
174
256
|
end
|
175
257
|
end
|
@@ -181,17 +263,52 @@ module HTX
|
|
181
263
|
# * +text+ - String to append to the compiled template string.
|
182
264
|
#
|
183
265
|
def append(text)
|
184
|
-
if
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
@compiled << @indent
|
266
|
+
return if text.nil? || text.empty?
|
267
|
+
|
268
|
+
if @close_count > 0
|
269
|
+
close_count = @close_count
|
270
|
+
@close_count = 0
|
271
|
+
|
272
|
+
append("htx.close(#{close_count unless close_count == 1})")
|
192
273
|
end
|
193
274
|
|
194
|
-
@
|
275
|
+
if @whitespace_buff
|
276
|
+
@statement_buff << @whitespace_buff
|
277
|
+
@whitespace_buff = nil
|
278
|
+
confirmed_newline = true
|
279
|
+
end
|
280
|
+
|
281
|
+
if (confirmed_newline || @statement_buff.match?(NEWLINE_END)) && !text.match?(NEWLINE_BEGIN)
|
282
|
+
@statement_buff << @indent
|
283
|
+
elsif !@statement_buff.match?(NO_SEMICOLON_END) && !text.match?(NO_SEMICOLON_BEGIN)
|
284
|
+
@statement_buff << ";#{' ' unless text.match?(WHITESPACE_BEGIN)}"
|
285
|
+
end
|
286
|
+
|
287
|
+
flush if text.match?(NON_WHITESPACE)
|
288
|
+
@statement_buff << text
|
289
|
+
end
|
290
|
+
|
291
|
+
##
|
292
|
+
# Appends an +htx.node+ call to the compiled template function string.
|
293
|
+
#
|
294
|
+
# * +args+ - Arguments to use for the +htx.node+ call (any +nil+ ones are removed).
|
295
|
+
#
|
296
|
+
def append_htx_node(*args)
|
297
|
+
return if args.first.nil?
|
298
|
+
|
299
|
+
args.compact!
|
300
|
+
args << 0 unless args.last.kind_of?(Integer)
|
301
|
+
args[-1] |= (@static_key += 1) << FLAG_BITS
|
302
|
+
|
303
|
+
append("htx.node(#{args.join(', ')})")
|
304
|
+
end
|
305
|
+
|
306
|
+
##
|
307
|
+
# Flushes statement buffer.
|
308
|
+
#
|
309
|
+
def flush
|
310
|
+
@compiled << @statement_buff
|
311
|
+
@statement_buff.clear
|
195
312
|
end
|
196
313
|
|
197
314
|
##
|
@@ -200,98 +317,89 @@ module HTX
|
|
200
317
|
# * +text+ - String of lines to indent.
|
201
318
|
#
|
202
319
|
def indent(text)
|
203
|
-
|
204
|
-
|
205
|
-
text.gsub!(NEWLINE_NON_BLANK, "\n#{@indent}")
|
320
|
+
text.gsub!(INDENT_REGEX, "\\0#{@indent}")
|
206
321
|
text
|
207
322
|
end
|
208
323
|
|
209
324
|
##
|
210
|
-
#
|
211
|
-
#
|
212
|
-
#
|
213
|
-
#
|
214
|
-
|
215
|
-
|
216
|
-
def process_value(text, is_attr = false)
|
217
|
-
return nil if text.nil? || (!is_attr && text.strip == '')
|
218
|
-
|
219
|
-
if (value = text[RAW_VALUE, 1])
|
220
|
-
# Entire text is enclosed in ${...}.
|
221
|
-
value.strip!
|
222
|
-
quote = false
|
223
|
-
elsif (value = text[TEMPLATE_STRING, 1])
|
224
|
-
# Entire text is enclosed in backticks (template string).
|
225
|
-
quote = true
|
226
|
-
elsif is_attr || text.gsub(NON_CONTROL_STATEMENT, '') !~ CONTROL_STATEMENT
|
227
|
-
# Text is an attribute value or doesn't match control statement pattern.
|
228
|
-
value = text.dup
|
229
|
-
quote = true
|
230
|
-
else
|
231
|
-
return nil
|
232
|
-
end
|
233
|
-
|
234
|
-
# Strip one leading and trailing newline (and attached spaces) and perform outdent. Outdent amount
|
235
|
-
# calculation ignores everything before the first newline in its search for the least-indented line.
|
236
|
-
outdent = value.scan(NON_BLANK_NON_FIRST_LINE).min
|
237
|
-
value.gsub!(/#{LEADING_WHITESPACE}|#{TRAILING_WHITESPACE}|^#{outdent}/, '')
|
238
|
-
value.insert(0, '`').insert(-1, '`') if quote
|
239
|
-
|
240
|
-
# Ensure any Unicode characters get converted to Unicode escape sequences. Also note that since
|
241
|
-
# Nokogiri converts HTML entities to Unicode characters, this causes them to be properly passed to
|
242
|
-
# `document.createTextNode` calls as Unicode escape sequences rather than (incorrectly) as HTML
|
243
|
-
# entities.
|
244
|
-
value.encode('ascii', fallback: ->(c) { "\\u#{c.ord.to_s(16).rjust(4, '0')}" })
|
325
|
+
# Returns true if the node is whitespace containing at least one newline.
|
326
|
+
#
|
327
|
+
# * +node+ - Nokogiri node to check.
|
328
|
+
#
|
329
|
+
def self.formatting_node?(node)
|
330
|
+
node.blank? && node.content.include?("\n")
|
245
331
|
end
|
246
332
|
|
247
333
|
##
|
248
|
-
#
|
249
|
-
# boolean indicating whether or not an xmlns attribute is present.
|
334
|
+
# Returns true if the node is an <htx-content> node (or one of its now-deprecated names).
|
250
335
|
#
|
251
|
-
#
|
252
|
-
# will be automatically added since it is required for those elements to render properly.
|
336
|
+
# * +node+ - Nokogiri node to check.
|
253
337
|
#
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
attrs = []
|
258
|
-
xmlns = !!node.attributes['xmlns']
|
259
|
-
|
260
|
-
if !xmlns && DEFAULT_XMLNS[node.name]
|
261
|
-
xmlns = true
|
338
|
+
def self.htx_content_node?(node)
|
339
|
+
node && (node.name == CONTENT_TAG || node.name == 'htx-text' || node.name == ':')
|
340
|
+
end
|
262
341
|
|
263
|
-
|
264
|
-
|
342
|
+
##
|
343
|
+
# Processes a node's attributes returning a flat array of attribute names and values.
|
344
|
+
#
|
345
|
+
# * +node+ - Nokogiri node to process the attributes of.
|
346
|
+
#
|
347
|
+
def self.process_attributes(node)
|
348
|
+
attributes = []
|
349
|
+
|
350
|
+
if !node.attribute('xmlns') && (xmlns = namespace(node))
|
351
|
+
attributes.push(
|
352
|
+
attribute_name('xmlns'),
|
353
|
+
attribute_value(xmlns)
|
354
|
+
)
|
265
355
|
end
|
266
356
|
|
267
|
-
node.
|
268
|
-
next if
|
357
|
+
node.attribute_nodes.each.with_object(attributes) do |attribute, attributes|
|
358
|
+
next if attribute.node_name == DYNAMIC_KEY_ATTR
|
269
359
|
|
270
|
-
|
271
|
-
|
360
|
+
attributes.push(
|
361
|
+
attribute_name(attribute.node_name),
|
362
|
+
attribute_value(attribute.value)
|
363
|
+
)
|
272
364
|
end
|
365
|
+
end
|
273
366
|
|
274
|
-
|
367
|
+
##
|
368
|
+
# Returns namespace URL of a Nokogiri node.
|
369
|
+
#
|
370
|
+
# * +node+ - Nokogiri node to get the namespace of.
|
371
|
+
#
|
372
|
+
def self.namespace(node)
|
373
|
+
node.namespace&.href || DEFAULT_XMLNS[node.name]
|
275
374
|
end
|
276
375
|
|
277
376
|
##
|
278
377
|
# Returns the given text if the HTML5 parser is in use, or looks up the value in the tag map to get the
|
279
378
|
# correctly-cased version, falling back to the supplied text if no mapping exists.
|
280
379
|
#
|
281
|
-
# * +text+ - Tag name as returned by Nokogiri
|
380
|
+
# * +text+ - Tag name as returned by Nokogiri.
|
282
381
|
#
|
283
|
-
def tag_name(text)
|
284
|
-
|
382
|
+
def self.tag_name(text)
|
383
|
+
html5_parser? ? text : (TAG_MAP[text] || text)
|
285
384
|
end
|
286
385
|
|
287
386
|
##
|
288
387
|
# Returns the given text if the HTML5 parser is in use, or looks up the value in the attribute map to
|
289
388
|
# get the correctly-cased version, falling back to the supplied text if no mapping exists.
|
290
389
|
#
|
291
|
-
# * +text+ - Attribute name as returned by Nokogiri
|
390
|
+
# * +text+ - Attribute name as returned by Nokogiri.
|
391
|
+
#
|
392
|
+
def self.attribute_name(text)
|
393
|
+
"'#{html5_parser? ? text : (ATTR_MAP[text] || text)}'"
|
394
|
+
end
|
395
|
+
|
396
|
+
##
|
397
|
+
# Returns the processed value of an attribute.
|
398
|
+
#
|
399
|
+
# * +text+ - Attribute value as returned by Nokogiri.
|
292
400
|
#
|
293
|
-
def
|
294
|
-
|
401
|
+
def self.attribute_value(text)
|
402
|
+
text ? TextParser.new(text, statement_allowed: false).parse : nil
|
295
403
|
end
|
296
404
|
|
297
405
|
# The Nokogiri HTML parser downcases all tag and attribute names, but SVG tags and attributes are case
|
@@ -300,9 +408,9 @@ module HTX
|
|
300
408
|
#
|
301
409
|
# Note: Nokogiri's newer HTML5 parser resulting from the Nokogumbo merge fixes this issue, but it is
|
302
410
|
# currently not available for JRuby. It also does not parse <:> as a tag, which is why it's been
|
303
|
-
# deprecated in favor of <htx-
|
304
|
-
# will be used for regular Ruby and this tag and attribute mapping hack reserved for JRuby (and
|
305
|
-
# other potential environments where the HTML5 parser is not available).
|
411
|
+
# deprecated in favor of <htx-content>. Once support for <:> has been completely removed, the HTML5
|
412
|
+
# parser will be used for regular Ruby and this tag and attribute mapping hack reserved for JRuby (and
|
413
|
+
# any other potential environments where the HTML5 parser is not available).
|
306
414
|
|
307
415
|
# Source: https://developer.mozilla.org/en-US/docs/Web/SVG/Element
|
308
416
|
TAG_MAP = %w[
|
@@ -0,0 +1,233 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require('strscan')
|
4
|
+
|
5
|
+
module HTX
|
6
|
+
class TextParser
|
7
|
+
LEADING = /\A((?:[^\S\n]*\n)+)?((?:[^\S\n])+)?(?=\S)/.freeze
|
8
|
+
TRAILING = /([\S\s]*?)(\s*?)(\n[^\S\n]*)?\z/.freeze
|
9
|
+
|
10
|
+
NOT_ESCAPED = /(?<=^|[^\\])(?:\\\\)*/.freeze
|
11
|
+
OF_INTEREST = /#{NOT_ESCAPED}(?<chars>[`'"]|\${)|(?<chars>{|}|\/\/|\/\*|\*\/|\n[^\S\n]*(?=\S))/.freeze
|
12
|
+
TERMINATOR = {
|
13
|
+
'\'' => '\'',
|
14
|
+
'"' => '"',
|
15
|
+
'//' => "\n",
|
16
|
+
'/*' => '*/',
|
17
|
+
}.freeze
|
18
|
+
|
19
|
+
TEXT = /\S|\A[^\S\n]+\z/.freeze
|
20
|
+
IDENTIFIER = /[_$a-zA-Z][_$a-zA-Z0-9]*/.freeze
|
21
|
+
ASSIGNMENT = /\s*(\+|&|\||\^|\/|\*\*|<<|&&|\?\?|\|\||\*|%|-|>>>)?=/.freeze
|
22
|
+
STATEMENT = /[{}]|(^|\s)#{IDENTIFIER}(\.#{IDENTIFIER})*(#{ASSIGNMENT}|\+\+|--|\[|\()/.freeze
|
23
|
+
|
24
|
+
attr_reader(:type, :content, :leading, :trailing, :whitespace_buff)
|
25
|
+
|
26
|
+
##
|
27
|
+
# Creates a new instance.
|
28
|
+
#
|
29
|
+
# * +text+ - Text to parse.
|
30
|
+
# * +statement_allowed+ - True if statements are allowed; false if text is always a template or raw (
|
31
|
+
# single interpolation).
|
32
|
+
#
|
33
|
+
def initialize(text, statement_allowed:)
|
34
|
+
@text = text
|
35
|
+
@statement_allowed = statement_allowed
|
36
|
+
end
|
37
|
+
|
38
|
+
##
|
39
|
+
# Returns true if parsed text is a statement.
|
40
|
+
#
|
41
|
+
def statement?
|
42
|
+
@type == :statement
|
43
|
+
end
|
44
|
+
|
45
|
+
##
|
46
|
+
# Returns true if parsed text is a single interpolation.
|
47
|
+
#
|
48
|
+
def raw?
|
49
|
+
@type == :raw
|
50
|
+
end
|
51
|
+
|
52
|
+
##
|
53
|
+
# Returns true if parsed text is a template.
|
54
|
+
#
|
55
|
+
def template?
|
56
|
+
@type == :template
|
57
|
+
end
|
58
|
+
|
59
|
+
##
|
60
|
+
# Parses text.
|
61
|
+
#
|
62
|
+
def parse
|
63
|
+
@type = nil
|
64
|
+
@content = +''
|
65
|
+
|
66
|
+
@first_indent = nil
|
67
|
+
@min_indent = nil
|
68
|
+
@last_indent = nil
|
69
|
+
|
70
|
+
@buffer = +''
|
71
|
+
@stack = []
|
72
|
+
curlies = []
|
73
|
+
ignore_end = nil
|
74
|
+
|
75
|
+
@has_text = false
|
76
|
+
@is_statement = false
|
77
|
+
@template_count = 0
|
78
|
+
@interpolation_count = 0
|
79
|
+
|
80
|
+
scanner = StringScanner.new(@text)
|
81
|
+
|
82
|
+
scanner.scan(LEADING)
|
83
|
+
@leading_newlines = scanner[1]
|
84
|
+
@leading_indent = @first_indent = scanner[2]
|
85
|
+
@leading = scanner[0] if @leading_newlines || @leading_indent
|
86
|
+
|
87
|
+
until scanner.eos?
|
88
|
+
if (scanned = scanner.scan_until(OF_INTEREST))
|
89
|
+
ignore = @stack.last == :ignore
|
90
|
+
template = @stack.last == :template
|
91
|
+
interpolation = @stack.last == :interpolation
|
92
|
+
can_ignore = (@stack.empty? && @statement_allowed) || interpolation
|
93
|
+
can_template = @stack.empty? || interpolation
|
94
|
+
can_interpolate = @stack.empty? || template
|
95
|
+
|
96
|
+
chars = scanner[:chars]
|
97
|
+
@buffer << scanned.chomp!(chars)
|
98
|
+
|
99
|
+
if chars[0] == "\n"
|
100
|
+
indent = chars.delete_prefix("\n")
|
101
|
+
|
102
|
+
if !@last_indent
|
103
|
+
@last_indent = indent
|
104
|
+
else
|
105
|
+
@min_indent = @last_indent if !@min_indent || @last_indent.size < @min_indent.size
|
106
|
+
@last_indent = indent
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
if can_ignore && (ignore_end = TERMINATOR[chars])
|
111
|
+
push_state(:ignore)
|
112
|
+
@buffer << chars
|
113
|
+
elsif ignore && chars == ignore_end
|
114
|
+
@buffer << chars
|
115
|
+
pop_state
|
116
|
+
ignore_end = nil
|
117
|
+
elsif can_template && chars == '`'
|
118
|
+
push_state(:template)
|
119
|
+
@buffer << chars
|
120
|
+
elsif template && chars == '`'
|
121
|
+
@buffer << chars
|
122
|
+
pop_state
|
123
|
+
elsif can_interpolate && chars == '${'
|
124
|
+
push_state(:interpolation)
|
125
|
+
curlies << 1
|
126
|
+
@buffer << chars
|
127
|
+
elsif interpolation && (curlies[-1] += (chars == '{' && 1) || (chars == '}' && -1) || 0) == 0
|
128
|
+
@buffer << chars
|
129
|
+
curlies.pop
|
130
|
+
pop_state
|
131
|
+
else
|
132
|
+
@buffer << chars
|
133
|
+
end
|
134
|
+
else
|
135
|
+
scanner.scan(TRAILING)
|
136
|
+
|
137
|
+
@buffer << scanner[1]
|
138
|
+
@trailing = scanner[2] == '' ? nil : scanner[2]
|
139
|
+
@whitespace_buff = scanner[3]
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
flush(@stack.last)
|
144
|
+
finalize
|
145
|
+
|
146
|
+
@content
|
147
|
+
end
|
148
|
+
|
149
|
+
private
|
150
|
+
|
151
|
+
##
|
152
|
+
# Determines type (statement, raw, or template) and adjust formatting accordingly. Called at the end of
|
153
|
+
# parsing.
|
154
|
+
#
|
155
|
+
def finalize
|
156
|
+
if @is_statement
|
157
|
+
@type = :statement
|
158
|
+
@content.insert(0, @leading) && @leading = nil if @leading
|
159
|
+
@content.insert(-1, @trailing) && @trailing = nil if @trailing
|
160
|
+
elsif !@has_text && @template_count == 0 && @interpolation_count == 1
|
161
|
+
@type = :raw
|
162
|
+
@content.delete_prefix!('${')
|
163
|
+
@content.delete_suffix!('}')
|
164
|
+
|
165
|
+
if @content.empty?
|
166
|
+
@type = :template
|
167
|
+
@content = '``'
|
168
|
+
end
|
169
|
+
else
|
170
|
+
@type = :template
|
171
|
+
|
172
|
+
if !@has_text && @template_count == 1 && @interpolation_count == 0
|
173
|
+
@quoted = true
|
174
|
+
@outdent = @min_indent
|
175
|
+
@content.delete_prefix!('`')
|
176
|
+
@content.delete_suffix!('`')
|
177
|
+
else
|
178
|
+
@outdent = [@first_indent, @min_indent, @last_indent].compact.min
|
179
|
+
end
|
180
|
+
|
181
|
+
@content.gsub!(/(?<=\n)([^\S\n]+$|#{@outdent})/, '') if @outdent && !@outdent.empty?
|
182
|
+
|
183
|
+
unless @quoted
|
184
|
+
@content.insert(0, @leading) && @leading = nil if @leading && !@leading_newlines
|
185
|
+
@content.insert(-1, @trailing) && @trailing = nil if @trailing && !@trailing.include?("\n")
|
186
|
+
end
|
187
|
+
|
188
|
+
@content.insert(0, '`').insert(-1, '`')
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
##
|
193
|
+
# Pushes a state onto the stack during parsing.
|
194
|
+
#
|
195
|
+
# * +state+ - State to push onto the stack.
|
196
|
+
#
|
197
|
+
def push_state(state)
|
198
|
+
flush if @stack.empty?
|
199
|
+
@stack << state
|
200
|
+
end
|
201
|
+
|
202
|
+
##
|
203
|
+
# Pops a state from the stack during parsing.
|
204
|
+
#
|
205
|
+
def pop_state
|
206
|
+
state = @stack.pop
|
207
|
+
|
208
|
+
if @stack.empty?
|
209
|
+
flush(state)
|
210
|
+
|
211
|
+
@interpolation_count += 1 if state == :interpolation
|
212
|
+
@template_count += 1 if state == :template
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
##
|
217
|
+
# Flushes buffer during parsing and performs statement detection if appropriate.
|
218
|
+
#
|
219
|
+
# * +state+ - Last state that was parsed.
|
220
|
+
#
|
221
|
+
def flush(state = nil)
|
222
|
+
return if @buffer.empty?
|
223
|
+
|
224
|
+
if !state || state == :text
|
225
|
+
@has_text ||= @buffer.match?(TEXT)
|
226
|
+
@is_statement ||= @buffer.match?(STATEMENT) if @statement_allowed
|
227
|
+
end
|
228
|
+
|
229
|
+
@content << @buffer
|
230
|
+
@buffer.clear
|
231
|
+
end
|
232
|
+
end
|
233
|
+
end
|
data/lib/htx/version.rb
CHANGED
data/lib/htx.rb
CHANGED
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
require('htx/malformed_template_error')
|
4
4
|
require('htx/template')
|
5
|
+
require('htx/text_parser')
|
5
6
|
require('htx/version')
|
6
7
|
|
7
8
|
##
|
@@ -13,8 +14,12 @@ module HTX
|
|
13
14
|
##
|
14
15
|
# Convenience method to create a new Template instance and compile it.
|
15
16
|
#
|
16
|
-
|
17
|
-
|
17
|
+
# * +name+ - Template name. Conventionally the path of the template file.
|
18
|
+
# * +content+ - Template content.
|
19
|
+
# * +options+ - Options to be passed to Template#compile.
|
20
|
+
#
|
21
|
+
def self.compile(name, content, options = EMPTY_HASH)
|
22
|
+
Template.new(name, content).compile(**options)
|
18
23
|
end
|
19
24
|
|
20
25
|
##
|
@@ -22,9 +27,8 @@ module HTX
|
|
22
27
|
# compilation. This method allows HTX.new calls to continue working (but support will be removed in the
|
23
28
|
# near future).
|
24
29
|
#
|
25
|
-
# * +name+ -
|
26
|
-
#
|
27
|
-
# * +content+ - Template content string.
|
30
|
+
# * +name+ - Template name. Conventionally the path of the template file.
|
31
|
+
# * +content+ - Template content.
|
28
32
|
#
|
29
33
|
def self.new(name, content)
|
30
34
|
warn('HTX.new is deprecated. Please use HTX::Template.new instead.')
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: htx
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.9
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Nate Pickens
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2022-
|
11
|
+
date: 2022-03-31 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: nokogiri
|
@@ -72,6 +72,7 @@ files:
|
|
72
72
|
- lib/htx.rb
|
73
73
|
- lib/htx/malformed_template_error.rb
|
74
74
|
- lib/htx/template.rb
|
75
|
+
- lib/htx/text_parser.rb
|
75
76
|
- lib/htx/version.rb
|
76
77
|
homepage: https://github.com/npickens/htx
|
77
78
|
licenses:
|