htx 0.0.5 → 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: 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.