rsx-rb 0.1.0
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 +7 -0
- data/CHANGELOG.md +34 -0
- data/LICENSE +21 -0
- data/README.md +1022 -0
- data/examples/components/button.rsx +22 -0
- data/examples/components/card.rsx +21 -0
- data/examples/components/sidebar.rsx +29 -0
- data/examples/components/theme.rsx +29 -0
- data/examples/components/user_table.rsx +64 -0
- data/examples/user_profile.rsx +51 -0
- data/examples/views/dashboard.html.rsx +26 -0
- data/exe/rsx +10 -0
- data/lib/rsx/attributes.rb +313 -0
- data/lib/rsx/cache.rb +102 -0
- data/lib/rsx/children.rb +78 -0
- data/lib/rsx/cli.rb +96 -0
- data/lib/rsx/codegen.rb +320 -0
- data/lib/rsx/compile_cache.rb +84 -0
- data/lib/rsx/component.rb +212 -0
- data/lib/rsx/context.rb +67 -0
- data/lib/rsx/errors.rb +27 -0
- data/lib/rsx/escape.rb +59 -0
- data/lib/rsx/helpers.rb +29 -0
- data/lib/rsx/loader.rb +194 -0
- data/lib/rsx/nodes.rb +26 -0
- data/lib/rsx/railtie.rb +81 -0
- data/lib/rsx/safe_string.rb +42 -0
- data/lib/rsx/tasks.rake +26 -0
- data/lib/rsx/template.rb +63 -0
- data/lib/rsx/template_handler.rb +35 -0
- data/lib/rsx/transformer.rb +1034 -0
- data/lib/rsx/version.rb +8 -0
- data/lib/rsx-rb.rb +4 -0
- data/lib/rsx.rb +430 -0
- metadata +86 -0
|
@@ -0,0 +1,1034 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSX
|
|
4
|
+
# Transforms .rsx source (Ruby with embedded JSX-style markup) into plain Ruby.
|
|
5
|
+
#
|
|
6
|
+
# The transformer is a single-pass scanner. Ruby is copied through verbatim
|
|
7
|
+
# while enough lexical state is tracked to know two things:
|
|
8
|
+
#
|
|
9
|
+
# 1. whether a "<" starts markup or is an operator, which comes down to
|
|
10
|
+
# whether an expression is expected at that position (exactly how Babel
|
|
11
|
+
# decides that a "<" opens JSX), and
|
|
12
|
+
# 2. where the `end` matching a `component` block is, so the block can be
|
|
13
|
+
# rewritten into a class definition.
|
|
14
|
+
#
|
|
15
|
+
# Generated Ruby keeps the same line numbering as the source, so exceptions
|
|
16
|
+
# raised inside a template point at the .rsx file and line the user wrote.
|
|
17
|
+
class Transformer
|
|
18
|
+
IDENT_START = /[A-Za-z_]/
|
|
19
|
+
IDENT_CHAR = /[A-Za-z0-9_]/
|
|
20
|
+
TAG_START = /[A-Za-z_>]/
|
|
21
|
+
TAG_CHAR = /[A-Za-z0-9_\-.:]/
|
|
22
|
+
ATTR_START = /[A-Za-z_@]/
|
|
23
|
+
ATTR_CHAR = /[A-Za-z0-9_\-.:]/
|
|
24
|
+
DIGIT = /[0-9]/
|
|
25
|
+
|
|
26
|
+
# Keywords after which an expression is expected.
|
|
27
|
+
OPENS_EXPRESSION = %w[
|
|
28
|
+
and begin break case do elsif else ensure if in next not or raise rescue
|
|
29
|
+
return then unless until when while yield
|
|
30
|
+
].to_h { |word| [word, true] }.freeze
|
|
31
|
+
|
|
32
|
+
# Keywords that terminate an expression.
|
|
33
|
+
CLOSES_EXPRESSION = %w[
|
|
34
|
+
end self nil true false __FILE__ __LINE__ __dir__ __method__
|
|
35
|
+
].to_h { |word| [word, true] }.freeze
|
|
36
|
+
|
|
37
|
+
# Keywords that open a block terminated by `end`.
|
|
38
|
+
BLOCK_KEYWORDS = %w[begin case class def module].to_h { |word| [word, true] }.freeze
|
|
39
|
+
|
|
40
|
+
# Block keywords that are also statement modifiers (`x if y`).
|
|
41
|
+
MODIFIER_KEYWORDS = %w[if unless while until].to_h { |word| [word, true] }.freeze
|
|
42
|
+
|
|
43
|
+
# Appended to compiled output when the file declares components, so a file
|
|
44
|
+
# of components can be told apart from a file of markup without running it.
|
|
45
|
+
COMPONENT_MARKER = "# rsx:components"
|
|
46
|
+
|
|
47
|
+
def self.transform(source, path: nil)
|
|
48
|
+
new(source, path: path).transform
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def initialize(source, path: nil)
|
|
52
|
+
@src = source.to_s
|
|
53
|
+
@path = path
|
|
54
|
+
@len = @src.length
|
|
55
|
+
@pos = 0
|
|
56
|
+
@line = 1
|
|
57
|
+
@out_line = 1
|
|
58
|
+
@prev = :start
|
|
59
|
+
@buffers = [+""]
|
|
60
|
+
@heredocs = []
|
|
61
|
+
@blocks = []
|
|
62
|
+
@pending_loop = false
|
|
63
|
+
@jsx_spans = []
|
|
64
|
+
@components = 0
|
|
65
|
+
# Static markup slots are keyed by a digest of the source, so recompiling
|
|
66
|
+
# the same file reuses the same slots instead of leaking new ones.
|
|
67
|
+
@codegen = Codegen.new(path: path, prefix: "#{Digest::SHA256.hexdigest(@src)[0, 10]}-")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def transform
|
|
71
|
+
scan(:toplevel)
|
|
72
|
+
unless @blocks.empty?
|
|
73
|
+
component = @blocks.reverse.find { |frame| frame[:kind] == :component }
|
|
74
|
+
if component
|
|
75
|
+
raise SyntaxError.new("unterminated `component #{component[:name]}` block",
|
|
76
|
+
path: @path, line: component[:line])
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The marker goes after the last line, so line numbers are unaffected.
|
|
81
|
+
if @components.positive?
|
|
82
|
+
write("\n") unless @buffers.first.empty? || @buffers.first.end_with?("\n")
|
|
83
|
+
write("#{COMPONENT_MARKER}\n")
|
|
84
|
+
end
|
|
85
|
+
@buffers.first
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.defines_components?(ruby)
|
|
89
|
+
ruby.include?(COMPONENT_MARKER)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
# Output
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def out
|
|
99
|
+
@buffers.last
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Copies n characters of source through to the output unchanged.
|
|
103
|
+
def copy(count = 1)
|
|
104
|
+
chunk = @src[@pos, count]
|
|
105
|
+
@pos += chunk.length
|
|
106
|
+
newlines = chunk.count("\n")
|
|
107
|
+
@line += newlines
|
|
108
|
+
@out_line += newlines
|
|
109
|
+
out << chunk
|
|
110
|
+
chunk
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Writes generated Ruby that has no direct source counterpart.
|
|
114
|
+
def write(text)
|
|
115
|
+
@out_line += text.count("\n")
|
|
116
|
+
out << text
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Collects output produced by the block instead of appending it.
|
|
120
|
+
def capture
|
|
121
|
+
@buffers.push(+"")
|
|
122
|
+
saved_line = @out_line
|
|
123
|
+
yield
|
|
124
|
+
@out_line = saved_line
|
|
125
|
+
@buffers.pop
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
# Character helpers
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def eof?
|
|
133
|
+
@pos >= @len
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def peek(offset = 0)
|
|
137
|
+
@pos + offset < @len ? @src[@pos + offset] : nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def lookahead(length, offset = 0)
|
|
141
|
+
@src[@pos + offset, length]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def advance(count = 1)
|
|
145
|
+
count.times do
|
|
146
|
+
@line += 1 if @src[@pos] == "\n"
|
|
147
|
+
@pos += 1
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def error(message, line: @line)
|
|
152
|
+
raise SyntaxError.new(message, path: @path, line: line)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
# Main scanner
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
# mode is :toplevel or :brace. In :brace mode scanning stops (without
|
|
160
|
+
# consuming) at the "}" that closes the current expression container.
|
|
161
|
+
def scan(mode)
|
|
162
|
+
braces = 0
|
|
163
|
+
|
|
164
|
+
until eof?
|
|
165
|
+
char = peek
|
|
166
|
+
|
|
167
|
+
case char
|
|
168
|
+
when "\n"
|
|
169
|
+
copy
|
|
170
|
+
consume_heredoc_bodies
|
|
171
|
+
@prev = :start
|
|
172
|
+
when " ", "\t", "\r", "\f", "\v"
|
|
173
|
+
copy
|
|
174
|
+
when "#"
|
|
175
|
+
copy_line_comment
|
|
176
|
+
when "'", '"', "`"
|
|
177
|
+
copy_quoted(char)
|
|
178
|
+
@prev = :value
|
|
179
|
+
when "%"
|
|
180
|
+
if @prev == :start && percent_literal?
|
|
181
|
+
copy_percent_literal
|
|
182
|
+
@prev = :value
|
|
183
|
+
else
|
|
184
|
+
copy_operator
|
|
185
|
+
end
|
|
186
|
+
when "<"
|
|
187
|
+
if heredoc_ahead?
|
|
188
|
+
copy_heredoc_header
|
|
189
|
+
elsif @prev == :start && jsx_ahead?
|
|
190
|
+
emit_jsx
|
|
191
|
+
else
|
|
192
|
+
copy_operator
|
|
193
|
+
end
|
|
194
|
+
when "/"
|
|
195
|
+
if @prev == :start
|
|
196
|
+
copy_regex
|
|
197
|
+
@prev = :value
|
|
198
|
+
else
|
|
199
|
+
copy_operator
|
|
200
|
+
end
|
|
201
|
+
when "?"
|
|
202
|
+
if @prev == :start && character_literal?
|
|
203
|
+
copy(2)
|
|
204
|
+
@prev = :value
|
|
205
|
+
else
|
|
206
|
+
copy_operator
|
|
207
|
+
end
|
|
208
|
+
when "{"
|
|
209
|
+
braces += 1
|
|
210
|
+
copy
|
|
211
|
+
@prev = :start
|
|
212
|
+
when "}"
|
|
213
|
+
return if mode == :brace && braces.zero?
|
|
214
|
+
|
|
215
|
+
braces -= 1
|
|
216
|
+
copy
|
|
217
|
+
@prev = :value
|
|
218
|
+
when "="
|
|
219
|
+
if block_comment_ahead?
|
|
220
|
+
copy_block_comment
|
|
221
|
+
else
|
|
222
|
+
copy_operator
|
|
223
|
+
end
|
|
224
|
+
else
|
|
225
|
+
if IDENT_START.match?(char) || char == "@" || char == "$"
|
|
226
|
+
scan_word
|
|
227
|
+
elsif DIGIT.match?(char)
|
|
228
|
+
copy_number
|
|
229
|
+
@prev = :value
|
|
230
|
+
else
|
|
231
|
+
copy_operator
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def copy_operator
|
|
238
|
+
char = peek
|
|
239
|
+
|
|
240
|
+
# `::` and `?.`-like sequences are copied whole so state stays accurate.
|
|
241
|
+
if char == ":" && peek(1) == ":"
|
|
242
|
+
copy(2)
|
|
243
|
+
@prev = :start
|
|
244
|
+
elsif char == ":" && symbol_ahead?
|
|
245
|
+
copy_symbol
|
|
246
|
+
@prev = :value
|
|
247
|
+
else
|
|
248
|
+
copy
|
|
249
|
+
@prev = case char
|
|
250
|
+
when ")", "]" then :value
|
|
251
|
+
else :start
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def copy_number
|
|
257
|
+
copy while !eof? && /[0-9a-zA-Z_]/.match?(peek)
|
|
258
|
+
if peek == "." && peek(1) && DIGIT.match?(peek(1))
|
|
259
|
+
copy
|
|
260
|
+
copy while !eof? && /[0-9a-zA-Z_]/.match?(peek)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def copy_line_comment
|
|
265
|
+
copy until eof? || peek == "\n"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def block_comment_ahead?
|
|
269
|
+
lookahead(6) == "=begin" && at_line_start?
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def at_line_start?
|
|
273
|
+
index = @pos - 1
|
|
274
|
+
index -= 1 while index >= 0 && (@src[index] == " " || @src[index] == "\t")
|
|
275
|
+
index.negative? || @src[index] == "\n"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def copy_block_comment
|
|
279
|
+
copy until eof? || (peek == "\n" && lookahead(4, 1) == "=end")
|
|
280
|
+
return if eof?
|
|
281
|
+
|
|
282
|
+
copy # newline
|
|
283
|
+
copy until eof? || peek == "\n"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# ------------------------------------------------------------------
|
|
287
|
+
# Literals
|
|
288
|
+
# ------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
def copy_quoted(quote)
|
|
291
|
+
copy # opening quote
|
|
292
|
+
interpolating = quote != "'"
|
|
293
|
+
|
|
294
|
+
until eof?
|
|
295
|
+
char = peek
|
|
296
|
+
if char == "\\"
|
|
297
|
+
copy(2)
|
|
298
|
+
elsif char == quote
|
|
299
|
+
copy
|
|
300
|
+
return
|
|
301
|
+
elsif interpolating && char == "#" && peek(1) == "{"
|
|
302
|
+
copy(2)
|
|
303
|
+
scan(:brace)
|
|
304
|
+
error("unterminated interpolation") if eof?
|
|
305
|
+
copy # closing brace
|
|
306
|
+
else
|
|
307
|
+
copy
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
error("unterminated string literal")
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
PERCENT_TYPES = "qQwWiIrsx"
|
|
315
|
+
PAIRS = { "(" => ")", "[" => "]", "{" => "}", "<" => ">" }.freeze
|
|
316
|
+
|
|
317
|
+
def percent_literal?
|
|
318
|
+
offset = 1
|
|
319
|
+
offset += 1 if peek(1) && PERCENT_TYPES.include?(peek(1))
|
|
320
|
+
delimiter = peek(offset)
|
|
321
|
+
return false if delimiter.nil?
|
|
322
|
+
|
|
323
|
+
!/[A-Za-z0-9\s=]/.match?(delimiter)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def copy_percent_literal
|
|
327
|
+
copy # %
|
|
328
|
+
copy if PERCENT_TYPES.include?(peek)
|
|
329
|
+
open = peek
|
|
330
|
+
close = PAIRS[open] || open
|
|
331
|
+
nesting = 0
|
|
332
|
+
copy # delimiter
|
|
333
|
+
|
|
334
|
+
until eof?
|
|
335
|
+
char = peek
|
|
336
|
+
if char == "\\"
|
|
337
|
+
copy(2)
|
|
338
|
+
elsif char == open && close != open
|
|
339
|
+
nesting += 1
|
|
340
|
+
copy
|
|
341
|
+
elsif char == close
|
|
342
|
+
copy
|
|
343
|
+
return if nesting.zero?
|
|
344
|
+
|
|
345
|
+
nesting -= 1
|
|
346
|
+
elsif char == "#" && peek(1) == "{"
|
|
347
|
+
copy(2)
|
|
348
|
+
scan(:brace)
|
|
349
|
+
copy unless eof?
|
|
350
|
+
else
|
|
351
|
+
copy
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
error("unterminated %-literal")
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def copy_regex
|
|
359
|
+
copy # opening slash
|
|
360
|
+
in_class = false
|
|
361
|
+
|
|
362
|
+
until eof?
|
|
363
|
+
char = peek
|
|
364
|
+
case char
|
|
365
|
+
when "\\" then copy(2)
|
|
366
|
+
when "[" then in_class = true; copy
|
|
367
|
+
when "]" then in_class = false; copy
|
|
368
|
+
when "#"
|
|
369
|
+
if peek(1) == "{"
|
|
370
|
+
copy(2)
|
|
371
|
+
scan(:brace)
|
|
372
|
+
copy unless eof?
|
|
373
|
+
else
|
|
374
|
+
copy
|
|
375
|
+
end
|
|
376
|
+
when "/"
|
|
377
|
+
if in_class
|
|
378
|
+
copy
|
|
379
|
+
else
|
|
380
|
+
copy
|
|
381
|
+
copy while !eof? && /[imxounse]/.match?(peek)
|
|
382
|
+
return
|
|
383
|
+
end
|
|
384
|
+
when "\n"
|
|
385
|
+
error("unterminated regexp literal")
|
|
386
|
+
else copy
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def character_literal?
|
|
392
|
+
after = peek(1)
|
|
393
|
+
return false if after.nil? || /\s/.match?(after)
|
|
394
|
+
|
|
395
|
+
following = peek(2)
|
|
396
|
+
following.nil? || !IDENT_CHAR.match?(following)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def symbol_ahead?
|
|
400
|
+
after = peek(1)
|
|
401
|
+
return false if after.nil?
|
|
402
|
+
|
|
403
|
+
IDENT_START.match?(after) || after == '"' || after == "'" || after == "@" || after == "$"
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def copy_symbol
|
|
407
|
+
copy # colon
|
|
408
|
+
if peek == '"' || peek == "'"
|
|
409
|
+
copy_quoted(peek)
|
|
410
|
+
return
|
|
411
|
+
end
|
|
412
|
+
copy while !eof? && (IDENT_CHAR.match?(peek) || peek == "@" || peek == "$")
|
|
413
|
+
copy if peek == "?" || peek == "!" || (peek == "=" && peek(1) != "=" && peek(1) != ">" && peek(1) != "~")
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# ------------------------------------------------------------------
|
|
417
|
+
# Heredocs
|
|
418
|
+
# ------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
def heredoc_ahead?
|
|
421
|
+
return false unless lookahead(2) == "<<"
|
|
422
|
+
|
|
423
|
+
offset = 2
|
|
424
|
+
squiggly = peek(offset) == "~" || peek(offset) == "-"
|
|
425
|
+
offset += 1 if squiggly
|
|
426
|
+
char = peek(offset)
|
|
427
|
+
return false if char.nil?
|
|
428
|
+
|
|
429
|
+
if char == '"' || char == "'" || char == "`"
|
|
430
|
+
squiggly || @prev == :start
|
|
431
|
+
elsif IDENT_START.match?(char)
|
|
432
|
+
# A bare `a << B` is a push, not a heredoc, unless an expression is
|
|
433
|
+
# expected here. `<<~` and `<<-` are unambiguous.
|
|
434
|
+
squiggly || (@prev == :start && /[A-Z_]/.match?(char))
|
|
435
|
+
else
|
|
436
|
+
false
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def copy_heredoc_header
|
|
441
|
+
copy(2)
|
|
442
|
+
indented = peek == "~" || peek == "-"
|
|
443
|
+
copy if indented
|
|
444
|
+
|
|
445
|
+
quote = (peek == '"' || peek == "'" || peek == "`") ? peek : nil
|
|
446
|
+
copy if quote
|
|
447
|
+
identifier = +""
|
|
448
|
+
while !eof? && IDENT_CHAR.match?(peek)
|
|
449
|
+
identifier << peek
|
|
450
|
+
copy
|
|
451
|
+
end
|
|
452
|
+
copy if quote
|
|
453
|
+
|
|
454
|
+
@heredocs << { id: identifier, indented: indented }
|
|
455
|
+
@prev = :value
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def consume_heredoc_bodies
|
|
459
|
+
return if @heredocs.empty?
|
|
460
|
+
|
|
461
|
+
pending = @heredocs
|
|
462
|
+
@heredocs = []
|
|
463
|
+
|
|
464
|
+
pending.each do |heredoc|
|
|
465
|
+
loop do
|
|
466
|
+
break if eof?
|
|
467
|
+
|
|
468
|
+
line_start = @pos
|
|
469
|
+
copy until eof? || peek == "\n"
|
|
470
|
+
text = @src[line_start...@pos]
|
|
471
|
+
copy unless eof?
|
|
472
|
+
terminator = heredoc[:indented] ? text.strip : text.chomp
|
|
473
|
+
break if terminator == heredoc[:id]
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# ------------------------------------------------------------------
|
|
479
|
+
# Words: identifiers, keywords, and RSX statements
|
|
480
|
+
# ------------------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
def scan_word
|
|
483
|
+
if peek == "@" || peek == "$"
|
|
484
|
+
copy
|
|
485
|
+
copy while peek == "@"
|
|
486
|
+
copy while !eof? && IDENT_CHAR.match?(peek)
|
|
487
|
+
@prev = :value
|
|
488
|
+
return
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
word = read_word_text
|
|
492
|
+
word_start = @pos
|
|
493
|
+
statement_position = @prev == :start
|
|
494
|
+
|
|
495
|
+
if statement_position
|
|
496
|
+
case word
|
|
497
|
+
when "component"
|
|
498
|
+
return if try_component_header
|
|
499
|
+
when "import"
|
|
500
|
+
return if try_import
|
|
501
|
+
when "export"
|
|
502
|
+
return if try_export
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
copy(word.length)
|
|
507
|
+
copy if (peek == "?" || peek == "!") && peek(1) != "="
|
|
508
|
+
|
|
509
|
+
case word
|
|
510
|
+
when "end"
|
|
511
|
+
close_block(word_start)
|
|
512
|
+
@prev = :value
|
|
513
|
+
when "def"
|
|
514
|
+
@blocks.push({ kind: :block, line: @line }) unless endless_def_ahead?
|
|
515
|
+
@prev = :start
|
|
516
|
+
when "do"
|
|
517
|
+
if @pending_loop
|
|
518
|
+
@pending_loop = false
|
|
519
|
+
else
|
|
520
|
+
@blocks.push({ kind: :block, line: @line })
|
|
521
|
+
end
|
|
522
|
+
@prev = :start
|
|
523
|
+
when "while", "until", "for"
|
|
524
|
+
if statement_position
|
|
525
|
+
@blocks.push({ kind: :block, line: @line })
|
|
526
|
+
@pending_loop = true
|
|
527
|
+
end
|
|
528
|
+
@prev = :start
|
|
529
|
+
when "if", "unless"
|
|
530
|
+
@blocks.push({ kind: :block, line: @line }) if statement_position
|
|
531
|
+
@prev = :start
|
|
532
|
+
else
|
|
533
|
+
if BLOCK_KEYWORDS.key?(word)
|
|
534
|
+
@blocks.push({ kind: :block, line: @line })
|
|
535
|
+
@prev = :start
|
|
536
|
+
elsif CLOSES_EXPRESSION.key?(word)
|
|
537
|
+
@prev = :value
|
|
538
|
+
elsif OPENS_EXPRESSION.key?(word)
|
|
539
|
+
@prev = :start
|
|
540
|
+
else
|
|
541
|
+
@prev = :value
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def read_word_text
|
|
547
|
+
offset = 0
|
|
548
|
+
offset += 1 while @pos + offset < @len && IDENT_CHAR.match?(@src[@pos + offset])
|
|
549
|
+
@src[@pos, offset]
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def close_block(body_end)
|
|
553
|
+
frame = @blocks.pop
|
|
554
|
+
return unless frame && frame[:kind] == :component
|
|
555
|
+
|
|
556
|
+
write("; #{component_epilogue(frame, body_end)}end")
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def component_epilogue(frame, body_end)
|
|
560
|
+
return "" unless static_component?(frame, body_end)
|
|
561
|
+
|
|
562
|
+
"rsx_static!; "
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# A component whose body is nothing but static markup can be rendered once
|
|
566
|
+
# and reused forever, which is the fastest path RSX has.
|
|
567
|
+
def static_component?(frame, body_end)
|
|
568
|
+
body = @src[frame[:body_start]...body_end].dup
|
|
569
|
+
spans = @jsx_spans.select { |span| span[0] >= frame[:body_start] && span[1] <= body_end }
|
|
570
|
+
return false if spans.empty?
|
|
571
|
+
return false unless spans.all? { |span| span[2] }
|
|
572
|
+
|
|
573
|
+
spans.reverse_each do |span|
|
|
574
|
+
body[(span[0] - frame[:body_start])...(span[1] - frame[:body_start])] = ""
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
# What is left over must be nothing but `return`, grouping parentheses,
|
|
578
|
+
# comments and whitespace for the body to count as purely static.
|
|
579
|
+
leftover = body.gsub(/^[ \t]*#.*$/, "").gsub(/[\s;()]/, "")
|
|
580
|
+
leftover.delete_prefix!("return")
|
|
581
|
+
leftover.empty?
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def endless_def_ahead?
|
|
585
|
+
offset = 0
|
|
586
|
+
offset += 1 while @pos + offset < @len && /[ \t]/.match?(@src[@pos + offset])
|
|
587
|
+
offset += 1 while @pos + offset < @len && /[A-Za-z0-9_.?!\[\]<>=+\-*\/%&|^~]/.match?(@src[@pos + offset])
|
|
588
|
+
|
|
589
|
+
if @src[@pos + offset] == "("
|
|
590
|
+
depth = 0
|
|
591
|
+
while @pos + offset < @len
|
|
592
|
+
char = @src[@pos + offset]
|
|
593
|
+
depth += 1 if char == "("
|
|
594
|
+
if char == ")"
|
|
595
|
+
depth -= 1
|
|
596
|
+
if depth.zero?
|
|
597
|
+
offset += 1
|
|
598
|
+
break
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
break if char == "\n"
|
|
602
|
+
|
|
603
|
+
offset += 1
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
offset += 1 while @pos + offset < @len && /[ \t]/.match?(@src[@pos + offset])
|
|
608
|
+
@src[@pos + offset] == "=" && !["=", "~", ">"].include?(@src[@pos + offset + 1])
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# ------------------------------------------------------------------
|
|
612
|
+
# component / import / export statements
|
|
613
|
+
# ------------------------------------------------------------------
|
|
614
|
+
|
|
615
|
+
# component Name[, options] do [|params|]
|
|
616
|
+
def try_component_header
|
|
617
|
+
match = /\Acomponent[ \t]+([A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)/.match(@src[@pos..])
|
|
618
|
+
unless match
|
|
619
|
+
lowercase = /\Acomponent[ \t]+([a-z_][A-Za-z0-9_]*)/.match(@src[@pos..])
|
|
620
|
+
if lowercase
|
|
621
|
+
error("component names must be constants, got `#{lowercase[1]}` " \
|
|
622
|
+
"(try `component #{lowercase[1].split('_').map(&:capitalize).join}`)")
|
|
623
|
+
end
|
|
624
|
+
return false
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
line = @line
|
|
628
|
+
name = match[1]
|
|
629
|
+
start = @pos
|
|
630
|
+
advance(match[0].length)
|
|
631
|
+
|
|
632
|
+
options = read_component_options
|
|
633
|
+
if options.nil?
|
|
634
|
+
# No `do` follows, so this was not a component header after all.
|
|
635
|
+
@pos = start
|
|
636
|
+
return false
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
params = read_component_params
|
|
640
|
+
body_start = @pos
|
|
641
|
+
|
|
642
|
+
write(%(::RSX.define_component("#{name}"#{options}) do; def rsx_render(#{params});))
|
|
643
|
+
@components += 1
|
|
644
|
+
@blocks.push({ kind: :component, name: name, line: line, body_start: body_start })
|
|
645
|
+
@prev = :start
|
|
646
|
+
true
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# Reads any options between the component name and `do`, e.g. `, cache: true`.
|
|
650
|
+
def read_component_options
|
|
651
|
+
start = @pos
|
|
652
|
+
depth = 0
|
|
653
|
+
|
|
654
|
+
until eof?
|
|
655
|
+
char = peek
|
|
656
|
+
case char
|
|
657
|
+
when "(", "[", "{" then depth += 1; advance
|
|
658
|
+
when ")", "]", "}" then depth -= 1; advance
|
|
659
|
+
when "'", '"'
|
|
660
|
+
capture { copy_quoted(char) }
|
|
661
|
+
when "#"
|
|
662
|
+
advance until eof? || peek == "\n"
|
|
663
|
+
when "d"
|
|
664
|
+
if depth.zero? && lookahead(2) == "do" && !identifier_char?(peek(2)) && !identifier_char?(@src[@pos - 1])
|
|
665
|
+
options = @src[start...@pos].strip
|
|
666
|
+
advance(2)
|
|
667
|
+
return "" if options.empty?
|
|
668
|
+
return options.start_with?(",") ? options : ", #{options}"
|
|
669
|
+
end
|
|
670
|
+
advance
|
|
671
|
+
else
|
|
672
|
+
advance
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
@pos = start
|
|
677
|
+
nil
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def identifier_char?(char)
|
|
681
|
+
!char.nil? && IDENT_CHAR.match?(char)
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
def read_component_params
|
|
685
|
+
advance while !eof? && /[ \t]/.match?(peek)
|
|
686
|
+
return "_props = nil" unless peek == "|"
|
|
687
|
+
|
|
688
|
+
advance
|
|
689
|
+
start = @pos
|
|
690
|
+
depth = 0
|
|
691
|
+
|
|
692
|
+
until eof?
|
|
693
|
+
char = peek
|
|
694
|
+
case char
|
|
695
|
+
when "(", "[", "{" then depth += 1; advance
|
|
696
|
+
when ")", "]", "}" then depth -= 1; advance
|
|
697
|
+
when "'", '"' then capture { copy_quoted(char) }
|
|
698
|
+
when "|"
|
|
699
|
+
if depth.zero?
|
|
700
|
+
params = @src[start...@pos].strip
|
|
701
|
+
advance
|
|
702
|
+
return params.empty? ? "_props = nil" : params
|
|
703
|
+
end
|
|
704
|
+
advance
|
|
705
|
+
when "\n" then error("unterminated component parameter list")
|
|
706
|
+
else advance
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
error("unterminated component parameter list")
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# import Name from "path" / import { A, B } from "path" / import "path"
|
|
714
|
+
def try_import
|
|
715
|
+
rest = @src[@pos..][/\A[^\n]*/]
|
|
716
|
+
named = /\Aimport[ \t]+([A-Z][A-Za-z0-9_:]*)[ \t]+from[ \t]+(["'])(.+?)\2[ \t]*;?[ \t]*\z/.match(rest)
|
|
717
|
+
listed = /\Aimport[ \t]+\{([^}]+)\}[ \t]+from[ \t]+(["'])(.+?)\2[ \t]*;?[ \t]*\z/.match(rest)
|
|
718
|
+
bare = /\Aimport[ \t]+(["'])(.+?)\1[ \t]*;?[ \t]*\z/.match(rest)
|
|
719
|
+
|
|
720
|
+
if named
|
|
721
|
+
advance(rest.length)
|
|
722
|
+
write(%(::RSX.import("#{named[3]}", as: %i[#{named[1]}], from: __FILE__)))
|
|
723
|
+
elsif listed
|
|
724
|
+
names = listed[1].split(",").map(&:strip).reject(&:empty?)
|
|
725
|
+
advance(rest.length)
|
|
726
|
+
write(%(::RSX.import("#{listed[3]}", as: %i[#{names.join(" ")}], from: __FILE__)))
|
|
727
|
+
elsif bare
|
|
728
|
+
advance(rest.length)
|
|
729
|
+
write(%(::RSX.import("#{bare[2]}", from: __FILE__)))
|
|
730
|
+
else
|
|
731
|
+
return false
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
@prev = :value
|
|
735
|
+
true
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
# export default Name / export Name
|
|
739
|
+
def try_export
|
|
740
|
+
rest = @src[@pos..][/\A[^\n]*/]
|
|
741
|
+
match = /\Aexport[ \t]+(default[ \t]+)?([A-Z][A-Za-z0-9_:]*)[ \t]*;?[ \t]*\z/.match(rest)
|
|
742
|
+
return false unless match
|
|
743
|
+
|
|
744
|
+
advance(rest.length)
|
|
745
|
+
if match[1]
|
|
746
|
+
write(%(::RSX.export_default(#{match[2]}, from: __FILE__)))
|
|
747
|
+
else
|
|
748
|
+
write(%(::RSX.export(#{match[2]}, from: __FILE__)))
|
|
749
|
+
end
|
|
750
|
+
@prev = :value
|
|
751
|
+
true
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
# ------------------------------------------------------------------
|
|
755
|
+
# JSX
|
|
756
|
+
# ------------------------------------------------------------------
|
|
757
|
+
|
|
758
|
+
def jsx_ahead?
|
|
759
|
+
after = peek(1)
|
|
760
|
+
!after.nil? && TAG_START.match?(after)
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
def emit_jsx
|
|
764
|
+
start_pos = @pos
|
|
765
|
+
start_line = @line
|
|
766
|
+
node = parse_node
|
|
767
|
+
end_line = @line
|
|
768
|
+
ruby, static = @codegen.compile(node, start_line: start_line, end_line: end_line)
|
|
769
|
+
@jsx_spans << [start_pos, @pos, static]
|
|
770
|
+
write(ruby)
|
|
771
|
+
@prev = :value
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
def parse_node
|
|
775
|
+
line = @line
|
|
776
|
+
advance # <
|
|
777
|
+
|
|
778
|
+
if peek == ">"
|
|
779
|
+
advance
|
|
780
|
+
children = parse_children("")
|
|
781
|
+
expect_closing_tag("")
|
|
782
|
+
return Nodes::Fragment.new(children, line)
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
tag = read_tag_name
|
|
786
|
+
error("expected a tag name after `<`") if tag.empty?
|
|
787
|
+
|
|
788
|
+
attributes = parse_attributes(tag)
|
|
789
|
+
|
|
790
|
+
if lookahead(2) == "/>"
|
|
791
|
+
advance(2)
|
|
792
|
+
return build_node(tag, attributes, [], true, line)
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
error("expected `>` to close <#{tag}>") unless peek == ">"
|
|
796
|
+
advance
|
|
797
|
+
|
|
798
|
+
# Void elements are complete at ">": HTML gives them no closing tag.
|
|
799
|
+
return build_node(tag, attributes, [], true, line) if Attributes.void?(tag)
|
|
800
|
+
|
|
801
|
+
children = parse_children(tag)
|
|
802
|
+
expect_closing_tag(tag)
|
|
803
|
+
build_node(tag, attributes, children, false, line)
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
def build_node(tag, attributes, children, self_closing, line)
|
|
807
|
+
if tag == "Fragment" || tag == "React.Fragment" || tag == "RSX::Fragment"
|
|
808
|
+
return Nodes::Fragment.new(children, line)
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
if component_tag?(tag)
|
|
812
|
+
Nodes::Component.new(tag, attributes, children, line)
|
|
813
|
+
else
|
|
814
|
+
Nodes::Element.new(tag, attributes, children, self_closing, line)
|
|
815
|
+
end
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
def component_tag?(tag)
|
|
819
|
+
/\A[A-Z]/.match?(tag) || tag.include?(".") || tag.include?("::")
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
def read_tag_name
|
|
823
|
+
start = @pos
|
|
824
|
+
advance while !eof? && TAG_CHAR.match?(peek)
|
|
825
|
+
@src[start...@pos]
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
def parse_attributes(tag)
|
|
829
|
+
attributes = []
|
|
830
|
+
|
|
831
|
+
loop do
|
|
832
|
+
skip_tag_whitespace
|
|
833
|
+
error("unterminated <#{tag}> tag") if eof?
|
|
834
|
+
break if peek == ">" || lookahead(2) == "/>"
|
|
835
|
+
|
|
836
|
+
line = @line
|
|
837
|
+
|
|
838
|
+
if peek == "{"
|
|
839
|
+
attributes << Nodes::Attribute.new(nil, read_spread, :spread, line)
|
|
840
|
+
next
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
unless ATTR_START.match?(peek)
|
|
844
|
+
error("unexpected `#{peek}` in <#{tag}> attributes")
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
name = read_attribute_name
|
|
848
|
+
skip_tag_whitespace
|
|
849
|
+
|
|
850
|
+
unless peek == "="
|
|
851
|
+
attributes << Nodes::Attribute.new(name, true, :boolean, line)
|
|
852
|
+
next
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
advance
|
|
856
|
+
skip_tag_whitespace
|
|
857
|
+
|
|
858
|
+
case peek
|
|
859
|
+
when '"', "'"
|
|
860
|
+
attributes << Nodes::Attribute.new(name, read_attribute_string, :static, line)
|
|
861
|
+
when "{"
|
|
862
|
+
advance
|
|
863
|
+
source = read_expression
|
|
864
|
+
error("unterminated `{` in <#{tag}> attributes") unless peek == "}"
|
|
865
|
+
advance
|
|
866
|
+
attributes << Nodes::Attribute.new(name, source, :expression, line)
|
|
867
|
+
else
|
|
868
|
+
error("attribute `#{name}` needs a quoted string or a {ruby} expression")
|
|
869
|
+
end
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
attributes
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
# Whitespace plus the comment styles allowed inside a tag.
|
|
876
|
+
def skip_tag_whitespace
|
|
877
|
+
loop do
|
|
878
|
+
advance while !eof? && /\s/.match?(peek)
|
|
879
|
+
|
|
880
|
+
if peek == "#" || lookahead(2) == "//"
|
|
881
|
+
advance until eof? || peek == "\n"
|
|
882
|
+
elsif lookahead(2) == "/*"
|
|
883
|
+
advance(2)
|
|
884
|
+
advance until eof? || lookahead(2) == "*/"
|
|
885
|
+
error("unterminated comment") if eof?
|
|
886
|
+
advance(2)
|
|
887
|
+
else
|
|
888
|
+
return
|
|
889
|
+
end
|
|
890
|
+
end
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
def read_attribute_name
|
|
894
|
+
start = @pos
|
|
895
|
+
advance
|
|
896
|
+
advance while !eof? && ATTR_CHAR.match?(peek)
|
|
897
|
+
@src[start...@pos]
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
def read_attribute_string
|
|
901
|
+
quote = peek
|
|
902
|
+
advance
|
|
903
|
+
value = +""
|
|
904
|
+
|
|
905
|
+
until eof?
|
|
906
|
+
char = peek
|
|
907
|
+
if char == "\\" && (peek(1) == quote || peek(1) == "\\")
|
|
908
|
+
value << peek(1)
|
|
909
|
+
advance(2)
|
|
910
|
+
elsif char == quote
|
|
911
|
+
advance
|
|
912
|
+
return value
|
|
913
|
+
else
|
|
914
|
+
value << char
|
|
915
|
+
advance
|
|
916
|
+
end
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
error("unterminated attribute value")
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
# Reads a {ruby} container, stopping at the brace that closes it. Scanning
|
|
923
|
+
# always restarts in expression position, so `<` opens a nested tag and `/`
|
|
924
|
+
# opens a regexp no matter what token preceded the surrounding tag.
|
|
925
|
+
def read_expression
|
|
926
|
+
@prev = :start
|
|
927
|
+
capture { scan(:brace) }
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
def read_spread
|
|
931
|
+
advance # {
|
|
932
|
+
if lookahead(3) == "..."
|
|
933
|
+
advance(3)
|
|
934
|
+
elsif lookahead(2) == "**"
|
|
935
|
+
advance(2)
|
|
936
|
+
else
|
|
937
|
+
error("spread attributes must be written `{**props}` or `{...props}`")
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
source = read_expression
|
|
941
|
+
error("unterminated spread attribute") unless peek == "}"
|
|
942
|
+
advance
|
|
943
|
+
source
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
def parse_children(tag)
|
|
947
|
+
children = []
|
|
948
|
+
text = +""
|
|
949
|
+
text_line = @line
|
|
950
|
+
|
|
951
|
+
loop do
|
|
952
|
+
error("unterminated <#{tag || ""}> element") if eof?
|
|
953
|
+
|
|
954
|
+
if peek == "<"
|
|
955
|
+
flush_text(children, text, text_line)
|
|
956
|
+
text = +""
|
|
957
|
+
break if lookahead(2) == "</"
|
|
958
|
+
|
|
959
|
+
children << parse_node
|
|
960
|
+
text_line = @line
|
|
961
|
+
elsif peek == "{"
|
|
962
|
+
flush_text(children, text, text_line)
|
|
963
|
+
text = +""
|
|
964
|
+
|
|
965
|
+
if lookahead(3) == "{/*"
|
|
966
|
+
skip_jsx_comment
|
|
967
|
+
else
|
|
968
|
+
line = @line
|
|
969
|
+
advance
|
|
970
|
+
source = read_expression
|
|
971
|
+
error("unterminated `{` expression") unless peek == "}"
|
|
972
|
+
advance
|
|
973
|
+
children << Nodes::Expression.new(source, line) unless source.strip.empty?
|
|
974
|
+
end
|
|
975
|
+
text_line = @line
|
|
976
|
+
else
|
|
977
|
+
text << peek
|
|
978
|
+
advance
|
|
979
|
+
end
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
children
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
def skip_jsx_comment
|
|
986
|
+
advance(3)
|
|
987
|
+
until eof?
|
|
988
|
+
if lookahead(3) == "*/}"
|
|
989
|
+
advance(3)
|
|
990
|
+
return
|
|
991
|
+
end
|
|
992
|
+
advance
|
|
993
|
+
end
|
|
994
|
+
error("unterminated {/* comment */}")
|
|
995
|
+
end
|
|
996
|
+
|
|
997
|
+
def flush_text(children, text, line)
|
|
998
|
+
return if text.empty?
|
|
999
|
+
|
|
1000
|
+
normalized = self.class.normalize_text(text)
|
|
1001
|
+
children << Nodes::Text.new(normalized, line) unless normalized.empty?
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
# JSX whitespace rules: indentation-only lines disappear, and remaining lines
|
|
1005
|
+
# are joined with a single space.
|
|
1006
|
+
def self.normalize_text(raw)
|
|
1007
|
+
lines = raw.split("\n", -1)
|
|
1008
|
+
return lines.first.to_s if lines.length == 1
|
|
1009
|
+
|
|
1010
|
+
kept = []
|
|
1011
|
+
lines.each_with_index do |line, index|
|
|
1012
|
+
text = line
|
|
1013
|
+
text = text.sub(/\A[ \t\r]+/, "") if index.positive?
|
|
1014
|
+
text = text.sub(/[ \t\r]+\z/, "") if index < lines.length - 1
|
|
1015
|
+
kept << text unless text.empty?
|
|
1016
|
+
end
|
|
1017
|
+
kept.join(" ")
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
def expect_closing_tag(tag)
|
|
1021
|
+
error("expected `</#{tag}>`") unless lookahead(2) == "</"
|
|
1022
|
+
advance(2)
|
|
1023
|
+
closing = read_tag_name
|
|
1024
|
+
advance while !eof? && /\s/.match?(peek)
|
|
1025
|
+
error("expected `>` in closing tag `</#{closing}`") unless peek == ">"
|
|
1026
|
+
advance
|
|
1027
|
+
|
|
1028
|
+
return if closing == tag
|
|
1029
|
+
return if tag == "" && closing == ""
|
|
1030
|
+
|
|
1031
|
+
error("closing tag `</#{closing}>` does not match opening tag `<#{tag.empty? ? '' : tag}>`")
|
|
1032
|
+
end
|
|
1033
|
+
end
|
|
1034
|
+
end
|