htx 0.0.5 → 0.0.8

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: 637943258561910b726a2956a9c9e683b1fe2180ab80803a484032ba56fb3101
4
- data.tar.gz: b4c6faf31037d5a8e0550e2331e6820d21349e9ca80bed18f9a6611a8004de5b
3
+ metadata.gz: b83c07872c223b61b939883d9f2f6bc45a1cecc380c4b21701d04a606e7ae381
4
+ data.tar.gz: bfbf43071b20b13be3895bf4e358617765f39f276ed1d37c91c93f09c024b48a
5
5
  SHA512:
6
- metadata.gz: 82e119f2932d7e96c17a2a336c6bcb88a27759d6657308ac91c4f1e0b6bd4c38000f5e4e45592b2603f6e9f71e6f1d670a4458745b172b157aa429a843d32970
7
- data.tar.gz: b2583375f80f0062ea9f5a73df2c72c34b728cf3e2c7b863fc4feb93e18e94aaf81a689d45933bcac3914f71b86ce16865cecec8cca86ef728d8a038c2b15eba
6
+ metadata.gz: 0dc4f9f8fad2b18266ca93a562fa1565361b330744e8f02134341c1a5982f0821a9b008975adcff7840626d85f05bca223dd433705799c227217584dab1f970c
7
+ data.tar.gz: 58756df107105643831511c4faff3f7ccdc96fdcd318d7f65833cb642b4c08be60ab2e2f9cc6ffb999a9980a63a8a15a9b0b8b94c0aab8a4f8447ee61909c5a4
data/LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright 2019-2021 Nate Pickens
1
+ Copyright 2019-2022 Nate Pickens
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
4
4
  documentation files (the "Software"), to deal in the Software without restriction, including without
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 ADDED
@@ -0,0 +1 @@
1
+ 0.0.8
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTX
4
+ ##
5
+ # Error class used when a problem is encountered processing a template.
6
+ #
7
+ class MalformedTemplateError < StandardError
8
+ ##
9
+ # Creates a new instance.
10
+ #
11
+ # * +message+ - Description of the error.
12
+ # * +name+ - Name of the template.
13
+ # * +node+ - Nokogiri node being processed when the error was encountered (optional).
14
+ #
15
+ def initialize(message, name, node = nil)
16
+ if node
17
+ line = node.line
18
+ line = node.parent.line if line < 1
19
+ line = nil if line == -1
20
+ end
21
+
22
+ super("Malformed template #{name}#{":#{line}" if line}: #{message}")
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,439 @@
1
+ # frozen_string_literal: true
2
+
3
+ require('nokogiri')
4
+
5
+ module HTX
6
+ class Template
7
+ ELEMENT = 1 << 0
8
+ CHILDLESS = 1 << 1
9
+ XMLNS = 1 << 2
10
+ FLAG_BITS = 3
11
+
12
+ INDENT_DEFAULT = ' '
13
+ CONTENT_TAG = 'htx-content'
14
+ DYNAMIC_KEY_ATTR = 'htx-key'
15
+ DEFAULT_XMLNS = {
16
+ 'math' => 'http://www.w3.org/1998/Math/MathML',
17
+ 'svg' => 'http://www.w3.org/2000/svg',
18
+ }.freeze
19
+
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
33
+
34
+ ##
35
+ # Returns false. In the near future when support for the <:> tag has been dropped (in favor of
36
+ # <htx-content>), will return true if Nokogiri's HTML5 parser is available. To use it now, monkey patch
37
+ # this method to return true.
38
+ #
39
+ def self.html5_parser?
40
+ false # !!defined?(Nokogiri::HTML5)
41
+ end
42
+
43
+ ##
44
+ # Returns Nokogiri's HTML5 parser if available and enabled, and Nokogiri's regular HTML parser
45
+ # otherwise.
46
+ #
47
+ def self.nokogiri_parser
48
+ html5_parser? ? Nokogiri::HTML5 : Nokogiri::HTML
49
+ end
50
+
51
+ ##
52
+ # Creates a new instance.
53
+ #
54
+ # * +name+ - Template name. Conventionally the path of the template file.
55
+ # * +content+ - Template content.
56
+ #
57
+ def initialize(name, content)
58
+ @name = name
59
+ @content = content
60
+ end
61
+
62
+ ##
63
+ # Compiles the HTX template.
64
+ #
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'
72
+ @indent =
73
+ if indent.kind_of?(Numeric)
74
+ ' ' * indent
75
+ elsif indent && !indent.match?(INDENT_VALID)
76
+ raise("Invalid indent value #{indent.inspect}: only spaces and tabs (but not both) are allowed")
77
+ else
78
+ indent || @content[INDENT_GUESS] || INDENT_DEFAULT
79
+ end
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)
91
+ process(doc)
92
+
93
+ @compiled
94
+ end
95
+
96
+ private
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
+
138
+ ##
139
+ # Processes a DOM node's descendents.
140
+ #
141
+ # * +node+ - Nokogiri node to process.
142
+ #
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
154
+
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"
163
+
164
+ node.children.each do |child|
165
+ process(child)
166
+ end
167
+
168
+ append("\n}\n",)
169
+ flush
170
+ end
171
+
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
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)
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)
234
+ else
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
255
+ end
256
+ end
257
+ end
258
+
259
+ ##
260
+ # Appends a string to the compiled template function string with appropriate punctuation and/or
261
+ # whitespace inserted.
262
+ #
263
+ # * +text+ - String to append to the compiled template string.
264
+ #
265
+ def append(text)
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})")
273
+ end
274
+
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
291
+ end
292
+
293
+ ##
294
+ # Appends an +htx.node+ call to the compiled template function string.
295
+ #
296
+ # * +args+ - Arguments to use for the +htx.node+ call (any +nil+ ones are removed).
297
+ #
298
+ def append_htx_node(*args)
299
+ return if args.first.nil?
300
+
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(', ')})")
306
+ end
307
+
308
+ ##
309
+ # Flushes statement buffer.
310
+ #
311
+ def flush
312
+ @compiled << @statement_buff
313
+ @statement_buff.clear
314
+
315
+ @compiled
316
+ end
317
+
318
+ ##
319
+ # Indents each line of a string (except the first).
320
+ #
321
+ # * +text+ - String of lines to indent.
322
+ #
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.
330
+ #
331
+ # * +node+ - Nokogiri node to check.
332
+ #
333
+ def self.formatting_node?(node)
334
+ node.blank? && node.content.include?("\n")
335
+ end
336
+
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
345
+
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
+ )
359
+ end
360
+
361
+ node.attribute_nodes.each.with_object(attributes) do |attribute, attributes|
362
+ next if attribute.node_name == DYNAMIC_KEY_ATTR
363
+
364
+ attributes.push(
365
+ attribute_name(attribute.node_name),
366
+ attribute_value(attribute.value)
367
+ )
368
+ end
369
+ end
370
+
371
+ ##
372
+ #
373
+ #
374
+ def self.namespace(node)
375
+ node.namespace&.href || DEFAULT_XMLNS[node.name]
376
+ end
377
+
378
+ ##
379
+ # Returns the given text if the HTML5 parser is in use, or looks up the value in the tag map to get the
380
+ # correctly-cased version, falling back to the supplied text if no mapping exists.
381
+ #
382
+ # * +text+ - Tag name as returned by Nokogiri.
383
+ #
384
+ def self.tag_name(text)
385
+ html5_parser? ? text : (TAG_MAP[text] || text)
386
+ end
387
+
388
+ ##
389
+ # Returns the given text if the HTML5 parser is in use, or looks up the value in the attribute map to
390
+ # get the correctly-cased version, falling back to the supplied text if no mapping exists.
391
+ #
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.
402
+ #
403
+ def self.attribute_value(text)
404
+ text ? TextParser.new(text, statement_allowed: false).parse : nil
405
+ end
406
+
407
+ # The Nokogiri HTML parser downcases all tag and attribute names, but SVG tags and attributes are case
408
+ # sensitive and often mix cased. These maps are used to restore the correct case of such tags and
409
+ # attributes.
410
+ #
411
+ # Note: Nokogiri's newer HTML5 parser resulting from the Nokogumbo merge fixes this issue, but it is
412
+ # currently not available for JRuby. It also does not parse <:> as a tag, which is why it's been
413
+ # deprecated in favor of <htx-content>. Once support for <:> has been completely removed, the HTML5
414
+ # parser will be used for regular Ruby and this tag and attribute mapping hack reserved for JRuby (and
415
+ # any other potential environments where the HTML5 parser is not available).
416
+
417
+ # Source: https://developer.mozilla.org/en-US/docs/Web/SVG/Element
418
+ TAG_MAP = %w[
419
+ animateMotion animateTransform clipPath feBlend feColorMatrix feComponentTransfer feComposite
420
+ feConvolveMatrix feDiffuseLighting feDisplacementMap feDistantLight feDropShadow feFlood feFuncA
421
+ feFuncB feFuncG feFuncR feGaussianBlur feImage feMerge feMergeNode feMorphology feOffset fePointLight
422
+ feSpecularLighting feSpotLight feTile feTurbulence foreignObject linearGradient radialGradient
423
+ textPath
424
+ ].map { |tag| [tag.downcase, tag] }.to_h.freeze
425
+
426
+ # Source: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute
427
+ ATTR_MAP = %w[
428
+ attributeName attributeType baseFrequency baseProfile calcMode clipPathUnits contentScriptType
429
+ contentStyleType diffuseConstant edgeMode filterRes filterUnits glyphRef gradientTransform
430
+ gradientUnits kernelMatrix kernelUnitLength keyPoints keySplines keyTimes lengthAdjust
431
+ limitingConeAngle markerHeight markerUnits markerWidth maskContentUnits maskUnits numOctaves
432
+ pathLength patternContentUnits patternTransform patternUnits pointsAtX pointsAtY pointsAtZ
433
+ preserveAlpha preserveAspectRatio primitiveUnits refX refY referrerPolicy repeatCount repeatDur
434
+ requiredExtensions requiredFeatures specularConstant specularExponent spreadMethod startOffset
435
+ stdDeviation stitchTiles surfaceScale systemLanguage tableValues targetX targetY textLength viewBox
436
+ viewTarget xChannelSelector yChannelSelector zoomAndPan
437
+ ].map { |attr| [attr.downcase, attr] }.to_h.freeze
438
+ end
439
+ end
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTX
4
+ VERSION = '0.0.8'
5
+ end
data/lib/htx.rb CHANGED
@@ -1,256 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require('nokogiri')
3
+ require('htx/malformed_template_error')
4
+ require('htx/template')
5
+ require('htx/text_parser')
6
+ require('htx/version')
4
7
 
5
8
  ##
6
9
  # A Ruby compiler for HTX templates.
7
10
  #
8
- class HTX
9
- VERSION = '0.0.5'
10
-
11
- CHILDLESS = 0b01
12
- TEXT_NODE = 0b10
13
- FLAG_BITS = 2
14
-
15
- INDENT_DEFAULT = ' '
16
- DYNAMIC_KEY_ATTR = 'htx-key'
17
-
18
- LEADING_WHITESPACE = /\A[ \t]*\n[ \t]*/.freeze
19
- TRAILING_WHITESPACE = /\n[ \t]*\z/.freeze
20
- NON_BLANK_NON_FIRST_LINE = /(?<=\n)[ \t]*(?=\S)/.freeze
21
- NEWLINE_NON_BLANK = /\n(?=[^\n])/.freeze
22
- INDENT_GUESS = /^[ \t]+/.freeze
23
-
24
- END_STATEMENT_END = /(;|\n|\{|\})[ \t]*\z/.freeze
25
- BEGIN_STATEMENT_END = /\A[ \t]*(;|\{|\n|\})/.freeze
26
- END_WHITESPACE = /\s\z/.freeze
27
- BEGIN_WHITESPACE = /\A\s/.freeze
28
-
29
- RAW_VALUE = /\A\s*\${([\S\s]*)}\s*\z/.freeze
30
- TEMPLATE_STRING = /\A\s*`([\S\s]*)`\s*\z/.freeze
31
- INTERPOLATION = /\$\\?{([^}]*})?/.freeze
32
- HTML_ENTITY = /&([a-zA-Z]+|#\d+|x[0-9a-fA-F]+);/.freeze
33
- NON_CONTROL_STATEMENT = /#{INTERPOLATION}|(#{HTML_ENTITY})/.freeze
34
- CONTROL_STATEMENT = /[{}();]/.freeze
35
- CLOSE_STATEMENT = /;?\s*htx\.close\((\d*)\);?(\s*)\z/.freeze
36
-
11
+ module HTX
37
12
  EMPTY_HASH = {}.freeze
38
13
 
39
14
  ##
40
- # Convenience method to create a new instance and immediately call compile on it.
41
- #
42
- def self.compile(name, template, options = EMPTY_HASH)
43
- new(name, template).compile(options)
44
- end
45
-
46
- ##
47
- # Creates a new HTX instance. Conventionally the path of the template file is used for the name, but it
48
- # can be anything.
49
- #
50
- def initialize(name, template)
51
- @name = name
52
- @template = template
53
- end
54
-
55
- ##
56
- # Compiles the HTX template. Options:
57
- #
58
- # * :indent - Indent output by this number of spaces if Numeric, or by this string if a String (if the
59
- # latter, may only contain space and tab characters).
60
- # * :assign_to - Assign the template function to this JavaScript object instead of the `window` object.
61
- #
62
- def compile(options = EMPTY_HASH)
63
- doc = Nokogiri::HTML::DocumentFragment.parse(@template)
64
- root_nodes = doc.children.select { |n| n.element? || (n.text? && n.text.strip != '') }
65
-
66
- if (text_node = root_nodes.find(&:text?))
67
- raise(MalformedTemplateError.new('text nodes are not allowed at root level', @name, text_node))
68
- elsif root_nodes.size == 0
69
- raise(MalformedTemplateError.new('a root node is required', @name))
70
- elsif root_nodes.size > 1
71
- raise(MalformedTemplateError.new("root node already defined on line #{root_nodes[0].line}", @name,
72
- root_nodes[1]))
73
- end
74
-
75
- @compiled = ''.dup
76
- @static_key = 0
77
-
78
- @indent =
79
- if options[:indent].kind_of?(Numeric)
80
- ' ' * options[:indent]
81
- elsif options[:indent].kind_of?(String) && options[:indent] !~ /^[ \t]+$/
82
- raise("Invalid :indent value #{options[:indent].inspect}: only spaces and tabs are allowed")
83
- else
84
- options[:indent] || @template[INDENT_GUESS] || INDENT_DEFAULT
85
- end
86
-
87
- process(doc)
88
- @compiled.rstrip!
89
-
90
- <<~EOS
91
- #{options[:assign_to] || 'window'}['#{@name}'] = function(htx) {
92
- #{@indent}#{@compiled}
93
- }
94
- EOS
95
- end
96
-
97
- private
98
-
99
- ##
100
- # Processes a DOM node's descendents.
15
+ # Convenience method to create a new Template instance and compile it.
101
16
  #
102
- def process(base)
103
- base.children.each do |node|
104
- next unless node.element? || node.text?
105
-
106
- dynamic_key = process_value(node.attr(DYNAMIC_KEY_ATTR), :attr)
107
-
108
- if node.text? || node.name == ':'
109
- if (non_text_node = node.children.find { |n| !n.text? })
110
- raise(MalformedTemplateError.new('dummy tags may not contain child tags', @name, non_text_node))
111
- end
112
-
113
- text = (node.text? ? node : node.children).text
114
-
115
- if (value = process_value(text))
116
- append(
117
- "#{indent(text[LEADING_WHITESPACE])}"\
118
- "htx.node(#{[
119
- value,
120
- dynamic_key,
121
- ((@static_key += 1) << FLAG_BITS) | TEXT_NODE,
122
- ].compact.join(', ')})"\
123
- "#{indent(text[TRAILING_WHITESPACE])}"
124
- )
125
- else
126
- append(indent(text))
127
- end
128
- else
129
- attrs = node.attributes.inject([]) do |attrs, (_, attr)|
130
- next attrs if attr.name == DYNAMIC_KEY_ATTR
131
-
132
- attrs << "'#{ATTR_MAP[attr.name] || attr.name}'"
133
- attrs << process_value(attr.value, :attr)
134
- end
135
-
136
- childless = node.children.empty? || (node.children.size == 1 && node.children[0].text.strip == '')
137
-
138
- append("htx.node(#{[
139
- "'#{TAG_MAP[node.name] || node.name}'",
140
- attrs,
141
- dynamic_key,
142
- ((@static_key += 1) << FLAG_BITS) | (childless ? CHILDLESS : 0),
143
- ].compact.flatten.join(', ')})")
144
-
145
- unless childless
146
- process(node)
147
-
148
- count = ''
149
- @compiled.sub!(CLOSE_STATEMENT) do
150
- count = $1 == '' ? 2 : $1.to_i + 1
151
- $2
152
- end
153
-
154
- append("htx.close(#{count})")
155
- end
156
- end
157
- end
158
- end
159
-
160
- ##
161
- # Appends a string to the compiled template function string with appropriate punctuation and/or whitespace
162
- # inserted.
17
+ # * +name+ - Template name. Conventionally the path of the template file.
18
+ # * +content+ - Template content.
19
+ # * +options+ - Options to be passed to Template#compile.
163
20
  #
164
- def append(text)
165
- if @compiled == ''
166
- # Do nothing.
167
- elsif @compiled !~ END_STATEMENT_END && text !~ BEGIN_STATEMENT_END
168
- @compiled << '; '
169
- elsif @compiled !~ END_WHITESPACE && text !~ BEGIN_WHITESPACE
170
- @compiled << ' '
171
- elsif @compiled[-1] == "\n"
172
- @compiled << @indent
173
- end
174
-
175
- @compiled << text
21
+ def self.compile(name, content, options = EMPTY_HASH)
22
+ Template.new(name, content).compile(**options)
176
23
  end
177
24
 
178
25
  ##
179
- # Indents each line of a string (except the first).
26
+ # DEPRECATED. Use HTX::Template.new instead. HTX was formerly a class that would be instantiated for
27
+ # compilation. This method allows HTX.new calls to continue working (but support will be removed in the
28
+ # near future).
180
29
  #
181
- def indent(text)
182
- return '' unless text
183
-
184
- text.gsub!(NEWLINE_NON_BLANK, "\n#{@indent}")
185
- text
186
- end
187
-
188
- ##
189
- # Processes, formats, and encodes an attribute or text node value. Returns nil if the value is determined
190
- # to be a control statement.
30
+ # * +name+ - Template name. Conventionally the path of the template file.
31
+ # * +content+ - Template content.
191
32
  #
192
- def process_value(text, is_attr = false)
193
- return nil if text.nil? || (!is_attr && text.strip == '')
194
-
195
- if (value = text[RAW_VALUE, 1])
196
- # Entire text is enclosed in ${...}.
197
- value.strip!
198
- quote = false
199
- elsif (value = text[TEMPLATE_STRING, 1])
200
- # Entire text is enclosed in backticks (template string).
201
- quote = true
202
- elsif is_attr || text.gsub(NON_CONTROL_STATEMENT, '') !~ CONTROL_STATEMENT
203
- # Text is an attribute value or doesn't match control statement pattern.
204
- value = text.dup
205
- quote = true
206
- else
207
- return nil
208
- end
33
+ def self.new(name, content)
34
+ warn('HTX.new is deprecated. Please use HTX::Template.new instead.')
209
35
 
210
- # Strip one leading and trailing newline (and attached spaces) and perform outdent. Outdent amount
211
- # calculation ignores everything before the first newline in its search for the least-indented line.
212
- outdent = value.scan(NON_BLANK_NON_FIRST_LINE).min
213
- value.gsub!(/#{LEADING_WHITESPACE}|#{TRAILING_WHITESPACE}|^#{outdent}/, '')
214
- value.insert(0, '`').insert(-1, '`') if quote
215
-
216
- # Ensure any Unicode characters get converted to Unicode escape sequences. Also note that since Nokogiri
217
- # converts HTML entities to Unicode characters, this causes them to be properly passed to
218
- # `document.createTextNode` calls as Unicode escape sequences rather than (incorrectly) as HTML
219
- # entities.
220
- value.encode('ascii', fallback: ->(c) { "\\u#{c.ord.to_s(16).rjust(4, '0')}" })
221
- end
222
-
223
- class MalformedTemplateError < StandardError
224
- def initialize(message, name, node = nil)
225
- if node
226
- line = node.line
227
- line = node.parent.line if line < 1
228
- line = nil if line == -1
229
- end
230
-
231
- super("Malformed template #{name}#{":#{line}" if line}: #{message}")
232
- end
36
+ Template.new(name, content)
233
37
  end
234
-
235
- # The Nokogiri HTML parser downcases all tag and attribute names, but SVG tags and attributes are case
236
- # sensitive and often mix cased. These maps are used to restore the correct case of such tags and
237
- # attributes.
238
- TAG_MAP = %w[
239
- animateMotion animateTransform clipPath feBlend feColorMatrix feComponentTransfer feComposite
240
- feConvolveMatrix feDiffuseLighting feDisplacementMap feDistantLight feDropShadow feFlood feFuncA feFuncB
241
- feFuncG feFuncR feGaussianBlur feImage feMerge feMergeNode feMorphology feOffset fePointLight
242
- feSpecularLighting feSpotLight feTile feTurbulence foreignObject linearGradient radialGradient textPath
243
- ].map { |tag| [tag.downcase, tag] }.to_h.freeze
244
-
245
- ATTR_MAP = %w[
246
- allowReorder attributeName attributeType autoReverse baseFrequency baseProfile calcMode clipPathUnits
247
- contentScriptType contentStyleType diffuseConstant edgeMode externalResourcesRequired filterRes
248
- filterUnits glyphRef gradientTransform gradientUnits kernelMatrix kernelUnitLength keyPoints keySplines
249
- keyTimes lengthAdjust limitingConeAngle markerHeight markerUnits markerWidth maskContentUnits maskUnits
250
- numOctaves pathLength patternContentUnits patternTransform patternUnits pointsAtX pointsAtY pointsAtZ
251
- preserveAlpha preserveAspectRatio primitiveUnits refX refY referrerPolicy repeatCount repeatDur
252
- requiredExtensions requiredFeatures specularConstant specularExponent spreadMethod startOffset
253
- stdDeviation stitchTiles surfaceScale systemLanguage tableValues targetX targetY textLength viewBox
254
- viewTarget xChannelSelector yChannelSelector zoomAndPan
255
- ].map { |attr| [attr.downcase, attr] }.to_h.freeze
256
38
  end
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.5
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: 2021-06-05 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
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '1.10'
19
+ version: '1.13'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '1.10'
26
+ version: '1.13'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: bundler
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -68,7 +68,12 @@ extra_rdoc_files: []
68
68
  files:
69
69
  - LICENSE
70
70
  - README.md
71
+ - VERSION
71
72
  - lib/htx.rb
73
+ - lib/htx/malformed_template_error.rb
74
+ - lib/htx/template.rb
75
+ - lib/htx/text_parser.rb
76
+ - lib/htx/version.rb
72
77
  homepage: https://github.com/npickens/htx
73
78
  licenses:
74
79
  - MIT
@@ -84,14 +89,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
84
89
  requirements:
85
90
  - - ">="
86
91
  - !ruby/object:Gem::Version
87
- version: 2.5.8
92
+ version: 2.6.0
88
93
  required_rubygems_version: !ruby/object:Gem::Requirement
89
94
  requirements:
90
95
  - - ">="
91
96
  - !ruby/object:Gem::Version
92
97
  version: '0'
93
98
  requirements: []
94
- rubygems_version: 3.2.3
99
+ rubygems_version: 3.2.32
95
100
  signing_key:
96
101
  specification_version: 4
97
102
  summary: A Ruby compiler for HTX templates.