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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6bb09e36ecc0e6a70df4fed4552ae20a9512eadd3ac57d86bd88fa278c8d6029
4
- data.tar.gz: e45e67362bc5422faa7df5330c5bd4940d4973c8ab0303cdd8207f0d7149efed
3
+ metadata.gz: 395620b3af20b4da17e4413632418481ee28d6d0fe6b46427af240475087efce
4
+ data.tar.gz: bc15b61294daaf218b3a3d5a106de7c9279a7d9e8cb7ebd01002fcd618491b1f
5
5
  SHA512:
6
- metadata.gz: 9eaea052f3b131de348f9ad20de712d55e180820d184a54356935aeacc3f2a7116266f2a4a6ff6e725ca44052a6c15350debcacd250b0f60be5ed0bb586aca26
7
- data.tar.gz: f0f3ca8fd975d7bbf3b8d26c65d62cda86caaf245dd8143be571a5a9b28088e2274fb89a04a5fba9c567ef549f1d9d5dbaad8fcbc9db1ee346f395cd863097df
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 as
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 = '/my/hot/template.htx'
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
- # Or to attach to a custom object instead of `window`:
32
+ # window['/components/crew.htx'] = function(htx) {
33
+ # // ...
34
+ # }
35
+
33
36
  HTX.compile(path, template, assign_to: 'myTemplates')
34
37
 
35
- # Result:
36
- #
37
- # window['/my/hot/template.htx'] = function(htx) {
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.6
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
- CHILDLESS = 0b001
8
- TEXT_NODE = 0b010
9
- XMLNS_NODE = 0b100
10
- FLAG_BITS = 3
7
+ ELEMENT = 1 << 0
8
+ CHILDLESS = 1 << 1
9
+ XMLNS = 1 << 2
10
+ FLAG_BITS = 3
11
11
 
12
12
  INDENT_DEFAULT = ' '
13
- TEXT_NODE_TAG = 'htx-text'
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
- LEADING_WHITESPACE = /\A[ \t]*\n[ \t]*/.freeze
22
- TRAILING_WHITESPACE = /\n[ \t]*\z/.freeze
23
- NON_BLANK_NON_FIRST_LINE = /(?<=\n)[ \t]*(?=\S)/.freeze
24
- NEWLINE_NON_BLANK = /\n(?=[^\n])/.freeze
25
- INDENT_GUESS = /^[ \t]+/.freeze
26
-
27
- END_STATEMENT_END = /(;|\n|\{|\})[ \t]*\z/.freeze
28
- BEGIN_STATEMENT_END = /\A[ \t]*(;|\{|\n|\})/.freeze
29
- END_WHITESPACE = /\s\z/.freeze
30
- BEGIN_WHITESPACE = /\A\s/.freeze
31
-
32
- RAW_VALUE = /\A\s*\${([\S\s]*)}\s*\z/.freeze
33
- TEMPLATE_STRING = /\A\s*`([\S\s]*)`\s*\z/.freeze
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-text>), will return true if Nokogiri's HTML5 parser is available. To use it now, monkey patch
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::DocumentFragment : Nokogiri::HTML::DocumentFragment
48
+ html5_parser? ? Nokogiri::HTML5 : Nokogiri::HTML
55
49
  end
56
50
 
57
51
  ##
58
- # Creates a new HTX instance.
52
+ # Creates a new instance.
59
53
  #
60
- # * +name+ - Name of the template. Conventionally the path of the template file is used for the name,
61
- # but it can be anything.
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
- # * +indent+ - Indent output by this number of spaces if Numeric, or by this string if a String (if the
73
- # latter, may only contain space and tab characters).
74
- # * +assign_to+ - Assign the template function to this JavaScript object instead of the <tt>window</tt>
75
- # object.
76
- #
77
- def compile(indent: nil, assign_to: 'window')
78
- doc = self.class.nokogiri_parser.parse(@content)
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.kind_of?(String) && indent !~ /^[ \t]+$/
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
- <<~EOS
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
- # * +base+ - Base Nokogiri node to start from.
141
+ # * +node+ - Nokogiri node to process.
118
142
  #
119
- def process(base)
120
- base.children.each do |node|
121
- next unless node.element? || node.text?
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
- dynamic_key = process_value(node.attr(DYNAMIC_KEY_ATTR), :attr)
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
- if node.text? || node.name == TEXT_NODE_TAG || node.name == ':'
126
- if node.name == ':'
127
- warn("#{@name}:#{node.line}: The <:> tag has been deprecated. Please use <#{TEXT_NODE_TAG}> "\
128
- 'for identical functionality.')
129
- end
164
+ node.children.each do |child|
165
+ process(child)
166
+ end
130
167
 
131
- if (non_text_node = node.children.find { |n| !n.text? })
132
- raise(MalformedTemplateError.new('text node tags may not contain child tags', @name,
133
- non_text_node))
134
- end
168
+ append("\n}\n",)
169
+ flush
170
+ end
135
171
 
136
- text = (node.text? ? node : node.children).text
137
-
138
- if (value = process_value(text))
139
- append(
140
- "#{indent(text[LEADING_WHITESPACE])}"\
141
- "htx.node(#{[
142
- value,
143
- dynamic_key,
144
- ((@static_key += 1) << FLAG_BITS) | TEXT_NODE,
145
- ].compact.join(', ')})"\
146
- "#{indent(text[TRAILING_WHITESPACE])}"
147
- )
148
- else
149
- append(indent(text))
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
- childless = node.children.empty? || (node.children.size == 1 && node.children[0].text.strip == '')
153
- attrs, xmlns = process_attrs(node)
154
-
155
- append("htx.node(#{[
156
- "'#{tag_name(node.name)}'",
157
- attrs,
158
- dynamic_key,
159
- ((@static_key += 1) << FLAG_BITS) | (childless ? CHILDLESS : 0) | (xmlns ? XMLNS_NODE : 0),
160
- ].compact.flatten.join(', ')})")
161
-
162
- unless childless
163
- process(node)
164
-
165
- count = ''
166
- @compiled.sub!(CLOSE_STATEMENT) do
167
- count = $1 == '' ? 2 : $1.to_i + 1
168
- $2
169
- end
170
-
171
- append("htx.close(#{count})")
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 @compiled == ''
185
- # Do nothing.
186
- elsif @compiled !~ END_STATEMENT_END && text !~ BEGIN_STATEMENT_END
187
- @compiled << '; '
188
- elsif @compiled !~ END_WHITESPACE && text !~ BEGIN_WHITESPACE
189
- @compiled << ' '
190
- elsif @compiled[-1] == "\n"
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
- @compiled << text
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
- return '' unless text
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
- # Processes, formats, and encodes an attribute or text node value. Returns nil if the value is
211
- # determined to be a control statement.
212
- #
213
- # * +text+ - String to process.
214
- # * +is_attr+ - Truthy if the text is an attribute value.
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
- # Processes a node's attributes, returning two items: a flat array of attribute names and values, and a
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
- # Note: if the node is a <math> or <svg> tag without an explicit xmlns attribute set, an appropriate one
252
- # will be automatically added since it is required for those elements to render properly.
336
+ # * +node+ - Nokogiri node to check.
253
337
  #
254
- # * +node+ - Nokogiri node to process for attributes.
255
- #
256
- def process_attrs(node)
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
- attrs << "'xmlns'"
264
- attrs << process_value(DEFAULT_XMLNS[node.name], :attr)
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.attributes.each do |_, attr|
268
- next if attr.name == DYNAMIC_KEY_ATTR
357
+ node.attribute_nodes.each.with_object(attributes) do |attribute, attributes|
358
+ next if attribute.node_name == DYNAMIC_KEY_ATTR
269
359
 
270
- attrs << "'#{attr_name(attr.name)}'"
271
- attrs << process_value(attr.value, :attr)
360
+ attributes.push(
361
+ attribute_name(attribute.node_name),
362
+ attribute_value(attribute.value)
363
+ )
272
364
  end
365
+ end
273
366
 
274
- [attrs, xmlns]
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 parser.
380
+ # * +text+ - Tag name as returned by Nokogiri.
282
381
  #
283
- def tag_name(text)
284
- self.class.html5_parser? ? text : (TAG_MAP[text] || text)
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 parser.
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 attr_name(text)
294
- self.class.html5_parser? ? text : (ATTR_MAP[text] || text)
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-text>. Once support for <:> has been completely removed, the HTML5 parser
304
- # will be used for regular Ruby and this tag and attribute mapping hack reserved for JRuby (and any
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HTX
4
- VERSION = '0.0.6'
4
+ VERSION = '0.0.9'
5
5
  end
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
- def self.compile(name, template, options = EMPTY_HASH)
17
- Template.new(name, template).compile(**options)
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+ - Name of the template. Conventionally the path of the template file is used for the name,
26
- # but it can be anything.
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.6
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-01-21 00:00:00.000000000 Z
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: