htx 0.0.7 → 0.0.8

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: dc1adc5f7b7b12da438f6c96c5a81b3585a8f9a8b761d54eee3895b5195898ec
4
- data.tar.gz: cfc75dd48759fdb2151db74de71ce9c7ae07a73e07d20af415e96a0a37c07bda
3
+ metadata.gz: b83c07872c223b61b939883d9f2f6bc45a1cecc380c4b21701d04a606e7ae381
4
+ data.tar.gz: bfbf43071b20b13be3895bf4e358617765f39f276ed1d37c91c93f09c024b48a
5
5
  SHA512:
6
- metadata.gz: 81660a171c9eb0a384b483f5391bd929bec1833aceef2128ecefcfba1190ea4343efbc02f286f241fcf435dcd3fd860fd7f9844a6c29358d84e5005b10e2a62b
7
- data.tar.gz: c8f764200a3e0f5fb19d1c19cc8f8e5a71209b4d0e8e87f7cf27941fbf0595090395ee141a136c67670be865de8dba50235d7f10ce8fc0ccd4a2cd060809af09
6
+ metadata.gz: 0dc4f9f8fad2b18266ca93a562fa1565361b330744e8f02134341c1a5982f0821a9b008975adcff7840626d85f05bca223dd433705799c227217584dab1f970c
7
+ data.tar.gz: 58756df107105643831511c4faff3f7ccdc96fdcd318d7f65833cb642b4c08be60ab2e2f9cc6ffb999a9980a63a8a15a9b0b8b94c0aab8a4f8447ee61909c5a4
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.7
1
+ 0.0.8
data/lib/htx/template.rb CHANGED
@@ -4,39 +4,32 @@ require('nokogiri')
4
4
 
5
5
  module HTX
6
6
  class Template
7
- ELEMENT = 0b001
8
- CHILDLESS = 0b010
9
- XMLNS = 0b100
7
+ ELEMENT = 1 << 0
8
+ CHILDLESS = 1 << 1
9
+ XMLNS = 1 << 2
10
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
- 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
- UNESCAPED_BACKTICK = /(?<!\\)((\\\\)*)`/.freeze
39
- 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
40
33
 
41
34
  ##
42
35
  # Returns false. In the near future when support for the <:> tag has been dropped (in favor of
@@ -52,15 +45,14 @@ module HTX
52
45
  # otherwise.
53
46
  #
54
47
  def self.nokogiri_parser
55
- html5_parser? ? Nokogiri::HTML5::DocumentFragment : Nokogiri::HTML::DocumentFragment
48
+ html5_parser? ? Nokogiri::HTML5 : Nokogiri::HTML
56
49
  end
57
50
 
58
51
  ##
59
- # Creates a new HTX instance.
52
+ # Creates a new instance.
60
53
  #
61
- # * +name+ - Name of the template. Conventionally the path of the template file is used for the name,
62
- # but it can be anything.
63
- # * +content+ - Template content string.
54
+ # * +name+ - Template name. Conventionally the path of the template file.
55
+ # * +content+ - Template content.
64
56
  #
65
57
  def initialize(name, content)
66
58
  @name = name
@@ -70,113 +62,196 @@ module HTX
70
62
  ##
71
63
  # Compiles the HTX template.
72
64
  #
73
- # * +indent+ - Indent output by this number of spaces if Numeric, or by this string if a String (if the
74
- # latter, may only contain space and tab characters).
75
- # * +assign_to+ - Assign the template function to this JavaScript object instead of the <tt>window</tt>
76
- # object.
77
- #
78
- def compile(indent: nil, assign_to: 'window')
79
- doc = self.class.nokogiri_parser.parse(@content)
80
- root_nodes = doc.children.select { |n| n.element? || (n.text? && n.text.strip != '') }
81
-
82
- if (text_node = root_nodes.find(&:text?))
83
- raise(MalformedTemplateError.new('text nodes are not allowed at root level', @name, text_node))
84
- elsif root_nodes.size == 0
85
- raise(MalformedTemplateError.new('a root node is required', @name))
86
- elsif root_nodes.size > 1
87
- raise(MalformedTemplateError.new("root node already defined on line #{root_nodes[0].line}", @name,
88
- root_nodes[1]))
89
- end
90
-
91
- @compiled = ''.dup
92
- @static_key = 0
93
-
65
+ # * +assign_to+ - JavaScript object to assign the template function to (default: <tt>window</tt>).
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'
94
72
  @indent =
95
73
  if indent.kind_of?(Numeric)
96
74
  ' ' * indent
97
- elsif indent.kind_of?(String) && indent !~ /^[ \t]+$/
98
- 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")
99
77
  else
100
78
  indent || @content[INDENT_GUESS] || INDENT_DEFAULT
101
79
  end
102
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)
103
91
  process(doc)
104
- @compiled.rstrip!
105
92
 
106
- <<~EOS
107
- #{assign_to}['#{@name}'] = function(htx) {
108
- #{@indent}#{@compiled}
109
- }
110
- EOS
93
+ @compiled
111
94
  end
112
95
 
113
96
  private
114
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
+
115
138
  ##
116
139
  # Processes a DOM node's descendents.
117
140
  #
118
- # * +base+ - Base Nokogiri node to start from.
141
+ # * +node+ - Nokogiri node to process.
119
142
  #
120
- def process(base, xmlns: false)
121
- base.children.each do |node|
122
- 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
123
154
 
124
- 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"
125
163
 
126
- if node.text? || node.name == CONTENT_TAG || node.name == 'htx-text' || node.name == ':'
127
- if !node.text? && node.name != CONTENT_TAG
128
- warn("#{@name}:#{node.line}: The <#{node.name}> tag has been deprecated. Please use "\
129
- "<#{CONTENT_TAG}> for identical functionality.")
130
- end
164
+ node.children.each do |child|
165
+ process(child)
166
+ end
131
167
 
132
- if (node.attributes.size - (dynamic_key ? 1 : 0)) != 0
133
- raise(MalformedTemplateError.new("<#{node.name}> tags may not have attributes other than "\
134
- "#{DYNAMIC_KEY_ATTR}", @name, node))
135
- end
168
+ append("\n}\n",)
169
+ flush
170
+ end
136
171
 
137
- if (non_text_node = node.children.find { |n| !n.text? })
138
- raise(MalformedTemplateError.new("<#{node.name}> tags may not contain child tags", @name,
139
- non_text_node))
140
- end
172
+ ##
173
+ # Processes an element node.
174
+ #
175
+ # * +node+ - Nokogiri node to process.
176
+ # * +xmlns+ - True if node is the descendent 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
141
190
 
142
- text = (node.text? ? node : node.children).text
143
-
144
- if (value = process_value(text))
145
- append(
146
- "#{indent(text[LEADING_WHITESPACE])}"\
147
- "htx.node(#{[
148
- value,
149
- dynamic_key,
150
- (@static_key += 1) << FLAG_BITS,
151
- ].compact.join(', ')})"\
152
- "#{indent(text[TRAILING_WHITESPACE])}"
153
- )
154
- else
155
- append(indent(text))
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)
156
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)
157
234
  else
158
- childless = node.children.empty? || (node.children.size == 1 && node.children[0].text.strip == '')
159
- attrs, explicit_xmlns = process_attrs(node)
160
- xmlns ||= explicit_xmlns
161
-
162
- append("htx.node(#{[
163
- "'#{tag_name(node.name)}'",
164
- attrs,
165
- dynamic_key,
166
- ((@static_key += 1) << FLAG_BITS) | ELEMENT | (childless ? CHILDLESS : 0) | (xmlns ? XMLNS : 0),
167
- ].compact.flatten.join(', ')})")
168
-
169
- unless childless
170
- process(node, xmlns: xmlns)
171
-
172
- count = ''
173
- @compiled.sub!(CLOSE_STATEMENT) do
174
- count = $1 == '' ? 2 : $1.to_i + 1
175
- $2
176
- end
177
-
178
- append("htx.close(#{count})")
179
- 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
180
255
  end
181
256
  end
182
257
  end
@@ -188,121 +263,145 @@ module HTX
188
263
  # * +text+ - String to append to the compiled template string.
189
264
  #
190
265
  def append(text)
191
- if @compiled == ''
192
- # Do nothing.
193
- elsif @compiled !~ END_STATEMENT_END && text !~ BEGIN_STATEMENT_END
194
- @compiled << '; '
195
- elsif @compiled !~ END_WHITESPACE && text !~ BEGIN_WHITESPACE
196
- @compiled << ' '
197
- elsif @compiled[-1] == "\n"
198
- @compiled << @indent
266
+ return @compiled 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})")
199
273
  end
200
274
 
201
- @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
+
290
+ @compiled
202
291
  end
203
292
 
204
293
  ##
205
- # Indents each line of a string (except the first).
294
+ # Appends an +htx.node+ call to the compiled template function string.
206
295
  #
207
- # * +text+ - String of lines to indent.
296
+ # * +args+ - Arguments to use for the +htx.node+ call (any +nil+ ones are removed).
208
297
  #
209
- def indent(text)
210
- return '' unless text
298
+ def append_htx_node(*args)
299
+ return if args.first.nil?
211
300
 
212
- text.gsub!(NEWLINE_NON_BLANK, "\n#{@indent}")
213
- text
301
+ args.compact!
302
+ args << 0 unless args.last.kind_of?(Integer)
303
+ args[-1] |= (@static_key += 1) << FLAG_BITS
304
+
305
+ append("htx.node(#{args.join(', ')})")
214
306
  end
215
307
 
216
308
  ##
217
- # Processes, formats, and encodes an attribute or text node value. Returns nil if the value is
218
- # determined to be a control statement.
219
- #
220
- # * +text+ - String to process.
221
- # * +is_attr+ - Truthy if the text is an attribute value.
222
- #
223
- def process_value(text, is_attr = false)
224
- return nil if text.nil? || (!is_attr && text.strip == '')
225
-
226
- if (value = text[RAW_VALUE, 1])
227
- # Entire text is enclosed in ${...}.
228
- value.strip!
229
- quote = false
230
- escape_quotes = false
231
- elsif (value = text[TEMPLATE_STRING, 1])
232
- # Entire text is enclosed in backticks (template string).
233
- quote = true
234
- escape_quotes = false
235
- elsif is_attr || text.gsub(NON_CONTROL_STATEMENT, '') !~ CONTROL_STATEMENT
236
- # Text is an attribute value or doesn't match control statement pattern.
237
- value = text.dup
238
- quote = true
239
- escape_quotes = true
240
- else
241
- return nil
242
- end
309
+ # Flushes statement buffer.
310
+ #
311
+ def flush
312
+ @compiled << @statement_buff
313
+ @statement_buff.clear
243
314
 
244
- # Strip one leading and trailing newline (and attached spaces) and perform outdent. Outdent amount
245
- # calculation ignores everything before the first newline in its search for the least-indented line.
246
- outdent = value.scan(NON_BLANK_NON_FIRST_LINE).min
247
- value.gsub!(/#{LEADING_WHITESPACE}|#{TRAILING_WHITESPACE}|^#{outdent}/, '')
248
- value.gsub!(UNESCAPED_BACKTICK, '\1\\\`') if escape_quotes
249
- value.insert(0, '`').insert(-1, '`') if quote
250
-
251
- # Ensure any Unicode characters get converted to Unicode escape sequences. Also note that since
252
- # Nokogiri converts HTML entities to Unicode characters, this causes them to be properly passed to
253
- # `document.createTextNode` calls as Unicode escape sequences rather than (incorrectly) as HTML
254
- # entities.
255
- value.encode('ascii', fallback: ->(c) { "\\u#{c.ord.to_s(16).rjust(4, '0')}" })
315
+ @compiled
256
316
  end
257
317
 
258
318
  ##
259
- # Processes a node's attributes, returning two items: a flat array of attribute names and values, and a
260
- # boolean indicating whether or not an xmlns attribute is present.
319
+ # Indents each line of a string (except the first).
261
320
  #
262
- # Note: if the node is a <math> or <svg> tag without an explicit xmlns attribute set, an appropriate one
263
- # will be automatically added since it is required for those elements to render properly.
321
+ # * +text+ - String of lines to indent.
264
322
  #
265
- # * +node+ - Nokogiri node to process for attributes.
323
+ def indent(text)
324
+ text.gsub!(INDENT_REGEX, "\\0#{@indent}")
325
+ text
326
+ end
327
+
328
+ ##
329
+ # Returns true if the node is whitespace containing at least one newline.
266
330
  #
267
- def process_attrs(node)
268
- attrs = []
269
- xmlns = !!node.attributes['xmlns']
331
+ # * +node+ - Nokogiri node to check.
332
+ #
333
+ def self.formatting_node?(node)
334
+ node.blank? && node.content.include?("\n")
335
+ end
270
336
 
271
- if !xmlns && DEFAULT_XMLNS[node.name]
272
- xmlns = true
337
+ ##
338
+ # Returns true if the node is an <htx-content> node (or one of its now-deprecated names).
339
+ #
340
+ # * +node+ - Nokogiri node to check.
341
+ #
342
+ def self.htx_content_node?(node)
343
+ node && (node.name == CONTENT_TAG || node.name == 'htx-text' || node.name == ':')
344
+ end
273
345
 
274
- attrs << "'xmlns'"
275
- attrs << process_value(DEFAULT_XMLNS[node.name], :attr)
346
+ ##
347
+ # Processes a node's attributes returning a flat array of attribute names and values.
348
+ #
349
+ # * +node+ - Nokogiri node to process the attributes of.
350
+ #
351
+ def self.process_attributes(node)
352
+ attributes = []
353
+
354
+ if !node.attribute('xmlns') && (xmlns = namespace(node))
355
+ attributes.push(
356
+ attribute_name('xmlns'),
357
+ attribute_value(xmlns)
358
+ )
276
359
  end
277
360
 
278
- node.attributes.each do |_, attr|
279
- next if attr.name == DYNAMIC_KEY_ATTR
361
+ node.attribute_nodes.each.with_object(attributes) do |attribute, attributes|
362
+ next if attribute.node_name == DYNAMIC_KEY_ATTR
280
363
 
281
- attrs << "'#{attr_name(attr.name)}'"
282
- attrs << process_value(attr.value, :attr)
364
+ attributes.push(
365
+ attribute_name(attribute.node_name),
366
+ attribute_value(attribute.value)
367
+ )
283
368
  end
369
+ end
284
370
 
285
- [attrs, xmlns]
371
+ ##
372
+ #
373
+ #
374
+ def self.namespace(node)
375
+ node.namespace&.href || DEFAULT_XMLNS[node.name]
286
376
  end
287
377
 
288
378
  ##
289
379
  # Returns the given text if the HTML5 parser is in use, or looks up the value in the tag map to get the
290
380
  # correctly-cased version, falling back to the supplied text if no mapping exists.
291
381
  #
292
- # * +text+ - Tag name as returned by Nokogiri parser.
382
+ # * +text+ - Tag name as returned by Nokogiri.
293
383
  #
294
- def tag_name(text)
295
- self.class.html5_parser? ? text : (TAG_MAP[text] || text)
384
+ def self.tag_name(text)
385
+ html5_parser? ? text : (TAG_MAP[text] || text)
296
386
  end
297
387
 
298
388
  ##
299
389
  # Returns the given text if the HTML5 parser is in use, or looks up the value in the attribute map to
300
390
  # get the correctly-cased version, falling back to the supplied text if no mapping exists.
301
391
  #
302
- # * +text+ - Attribute name as returned by Nokogiri parser.
392
+ # * +text+ - Attribute name as returned by Nokogiri.
393
+ #
394
+ def self.attribute_name(text)
395
+ "'#{html5_parser? ? text : (ATTR_MAP[text] || text)}'"
396
+ end
397
+
398
+ ##
399
+ # Returns the processed value of an attribute.
400
+ #
401
+ # * +text+ - Attribute value as returned by Nokogiri.
303
402
  #
304
- def attr_name(text)
305
- self.class.html5_parser? ? text : (ATTR_MAP[text] || text)
403
+ def self.attribute_value(text)
404
+ text ? TextParser.new(text, statement_allowed: false).parse : nil
306
405
  end
307
406
 
308
407
  # The Nokogiri HTML parser downcases all tag and attribute names, but SVG tags and attributes are case
@@ -0,0 +1,231 @@
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
+ def flush(state = nil)
220
+ return if @buffer.empty?
221
+
222
+ if !state || state == :text
223
+ @has_text ||= @buffer.match?(TEXT)
224
+ @is_statement ||= @buffer.match?(STATEMENT) if @statement_allowed
225
+ end
226
+
227
+ @content << @buffer
228
+ @buffer.clear
229
+ end
230
+ end
231
+ 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.7'
4
+ VERSION = '0.0.8'
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.7
4
+ version: 0.0.8
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-02-09 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: