htx 0.0.6 → 0.0.9

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.
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: