grsx 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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/main.yml +20 -0
  3. data/.gitignore +5 -0
  4. data/.rspec +2 -0
  5. data/.travis.yml +6 -0
  6. data/Appraisals +17 -0
  7. data/CODE_OF_CONDUCT.md +74 -0
  8. data/Dockerfile +8 -0
  9. data/Gemfile +7 -0
  10. data/Gemfile.lock +274 -0
  11. data/Guardfile +70 -0
  12. data/LICENSE.txt +21 -0
  13. data/README.md +437 -0
  14. data/Rakefile +6 -0
  15. data/bin/console +14 -0
  16. data/bin/setup +8 -0
  17. data/bin/test +43 -0
  18. data/docker-compose.yml +29 -0
  19. data/gemfiles/.bundle/config +2 -0
  20. data/gemfiles/rails_6_1.gemfile +8 -0
  21. data/gemfiles/rails_6_1.gemfile.lock +260 -0
  22. data/gemfiles/rails_7_0.gemfile +7 -0
  23. data/gemfiles/rails_7_0.gemfile.lock +265 -0
  24. data/gemfiles/rails_7_1.gemfile +7 -0
  25. data/gemfiles/rails_7_1.gemfile.lock +295 -0
  26. data/gemfiles/rails_7_2.gemfile +7 -0
  27. data/gemfiles/rails_7_2.gemfile.lock +290 -0
  28. data/gemfiles/rails_8_0.gemfile +8 -0
  29. data/gemfiles/rails_8_0.gemfile.lock +344 -0
  30. data/gemfiles/rails_8_1.gemfile +8 -0
  31. data/gemfiles/rails_8_1.gemfile.lock +313 -0
  32. data/gemfiles/rails_master.gemfile +7 -0
  33. data/gemfiles/rails_master.gemfile.lock +296 -0
  34. data/grsx.gemspec +43 -0
  35. data/lib/generators/grsx/phlex_component/phlex_component_generator.rb +40 -0
  36. data/lib/generators/grsx/phlex_component/templates/component.rb.tt +12 -0
  37. data/lib/generators/grsx/phlex_component/templates/component.rbx.tt +6 -0
  38. data/lib/grsx/component_resolver.rb +64 -0
  39. data/lib/grsx/configuration.rb +14 -0
  40. data/lib/grsx/lexer.rb +325 -0
  41. data/lib/grsx/nodes/abstract_attr.rb +12 -0
  42. data/lib/grsx/nodes/abstract_element.rb +13 -0
  43. data/lib/grsx/nodes/abstract_node.rb +31 -0
  44. data/lib/grsx/nodes/component_element.rb +69 -0
  45. data/lib/grsx/nodes/component_prop.rb +29 -0
  46. data/lib/grsx/nodes/declaration.rb +15 -0
  47. data/lib/grsx/nodes/expression.rb +15 -0
  48. data/lib/grsx/nodes/expression_group.rb +15 -0
  49. data/lib/grsx/nodes/fragment.rb +30 -0
  50. data/lib/grsx/nodes/html_attr.rb +13 -0
  51. data/lib/grsx/nodes/html_element.rb +49 -0
  52. data/lib/grsx/nodes/newline.rb +9 -0
  53. data/lib/grsx/nodes/raw.rb +23 -0
  54. data/lib/grsx/nodes/root.rb +19 -0
  55. data/lib/grsx/nodes/text.rb +15 -0
  56. data/lib/grsx/nodes/util.rb +9 -0
  57. data/lib/grsx/nodes.rb +20 -0
  58. data/lib/grsx/parser.rb +238 -0
  59. data/lib/grsx/phlex_compiler.rb +223 -0
  60. data/lib/grsx/phlex_component.rb +361 -0
  61. data/lib/grsx/phlex_runtime.rb +70 -0
  62. data/lib/grsx/prop_inspector.rb +52 -0
  63. data/lib/grsx/rails/engine.rb +24 -0
  64. data/lib/grsx/rails/phlex_reloader.rb +25 -0
  65. data/lib/grsx/template.rb +12 -0
  66. data/lib/grsx/version.rb +3 -0
  67. data/lib/grsx.rb +35 -0
  68. metadata +324 -0
data/lib/grsx/lexer.rb ADDED
@@ -0,0 +1,325 @@
1
+ module Grsx
2
+ class Lexer
3
+ class SyntaxError < StandardError
4
+ attr_reader :line, :path
5
+
6
+ def initialize(lexer)
7
+ @line = lexer.current_line
8
+ # Template#identifier is the file path; Anonymous is an empty String.
9
+ ident = lexer.template.identifier
10
+ @path = ident.to_s.empty? ? nil : ident.to_s
11
+
12
+ location = path ? "#{File.basename(path)}:#{line}" : "line #{line}"
13
+ snippet = lexer.scanner.peek(30).chomp.inspect
14
+
15
+ super("#{location}: unexpected token near #{snippet}")
16
+ end
17
+ end
18
+
19
+ PATTERNS = {
20
+ open_expression: /{/,
21
+ close_expression: /}/,
22
+ expression_content: /[^}{"'<]+/,
23
+ open_tag_def: /<(?!\/)/,
24
+ open_tag_end: /<\//,
25
+ close_tag: /\s*\/?>/ ,
26
+ close_self_closing_tag: /\s*\/>/,
27
+ tag_name: /\/?[A-Za-z0-9\-_.]+/,
28
+ text_content: /[^<{#]+/,
29
+ comment: /^\p{Blank}*#.*(\n|\z)/,
30
+ whitespace: /\s+/,
31
+ attr: /[A-Za-z0-9\-_\.:]+/,
32
+ open_attr_splat: /{\*\*/,
33
+ attr_assignment: /=/,
34
+ double_quote: /"/,
35
+ single_quote: /'/,
36
+ double_quoted_text_content: /[^"]+/,
37
+ single_quoted_text_content: /[^']+/,
38
+ expression_internal_tag_prefixes: /(\s+(&&|\|\||\?|:|do|do\s*\|[^\|]+\||{|{\s*\|[^\|]+\|)\s+\z|\A\s*\z)/,
39
+ declaration: /<![^>]*>/
40
+ }.freeze
41
+
42
+ attr_reader :stack, :tokens, :scanner, :element_resolver, :template
43
+ attr_accessor :curr_expr, :curr_default_text, :curr_quoted_text
44
+
45
+ def initialize(template, element_resolver)
46
+ @template = template
47
+ @scanner = StringScanner.new(template.source)
48
+ @element_resolver = element_resolver
49
+ @stack = [:default]
50
+ @curr_expr = ""
51
+ @curr_default_text = ""
52
+ @curr_quoted_text = ""
53
+ @tokens = []
54
+ @line = 1
55
+ end
56
+
57
+ # Current line number (1-indexed). Derived from the scanner's current
58
+ # position — counts newlines consumed so far. Called only on errors
59
+ # so O(n) is acceptable.
60
+ def current_line
61
+ scanner.string[0, scanner.pos].count("\n") + 1
62
+ end
63
+
64
+ def tokenize
65
+ until scanner.eos?
66
+ case stack.last
67
+ when :default
68
+ if scanner.scan(PATTERNS[:declaration])
69
+ tokens << [:DECLARATION, scanner.matched]
70
+ elsif scanner.scan(PATTERNS[:open_tag_def])
71
+ open_tag_def
72
+ elsif scanner.scan(PATTERNS[:open_expression])
73
+ open_expression
74
+ elsif scanner.scan(PATTERNS[:comment])
75
+ tokens << [:NEWLINE]
76
+ elsif scanner.check(PATTERNS[:text_content])
77
+ stack.push(:default_text)
78
+ else
79
+ raise SyntaxError, self
80
+ end
81
+ when :tag
82
+ if scanner.scan(PATTERNS[:open_tag_def])
83
+ open_tag_def
84
+ elsif scanner.scan(PATTERNS[:open_tag_end])
85
+ # Check if this is </> (fragment close) vs </tagname>
86
+ if scanner.check(/>\s*/)
87
+ scanner.scan(/>\s*/)
88
+ tokens << [:CLOSE_FRAGMENT]
89
+ stack.pop
90
+ else
91
+ tokens << [:OPEN_TAG_END]
92
+ stack.push(:tag_end)
93
+ end
94
+ elsif scanner.scan(PATTERNS[:open_expression])
95
+ open_expression
96
+ elsif scanner.scan(PATTERNS[:comment])
97
+ tokens << [:NEWLINE]
98
+ elsif scanner.check(PATTERNS[:text_content])
99
+ stack.push(:default_text)
100
+ else
101
+ raise SyntaxError, self
102
+ end
103
+ when :default_text
104
+ if scanner.scan(PATTERNS[:text_content])
105
+ self.curr_default_text << scanner.matched
106
+ if scanner.matched.end_with?('\\') && scanner.peek(1) == "{"
107
+ self.curr_default_text << scanner.getch
108
+ elsif scanner.matched.end_with?('\\') && scanner.peek(1) == "#"
109
+ self.curr_default_text << scanner.getch
110
+ else
111
+ if scanner.peek(1) == "#"
112
+ # If the next token is a comment, trim trailing whitespace from
113
+ # the text value so we don't add to the indentation of the next
114
+ # value that is output after the comment
115
+ self.curr_default_text = curr_default_text.gsub(/^\p{Blank}*\z/, "")
116
+ end
117
+ tokens << [:TEXT, curr_default_text]
118
+ self.curr_default_text = ""
119
+ stack.pop
120
+ end
121
+ else
122
+ raise SyntaxError, self
123
+ end
124
+ when :expression
125
+ if scanner.scan(PATTERNS[:close_expression])
126
+ tokens << [:EXPRESSION_BODY, curr_expr]
127
+ tokens << [:CLOSE_EXPRESSION]
128
+ self.curr_expr = ""
129
+ stack.pop
130
+ elsif scanner.scan(PATTERNS[:open_expression])
131
+ expression_inner_bracket
132
+ elsif scanner.scan(PATTERNS[:double_quote])
133
+ expression_inner_double_quote
134
+ elsif scanner.scan(PATTERNS[:single_quote])
135
+ expression_inner_single_quote
136
+ elsif scanner.scan(PATTERNS[:open_tag_def])
137
+ potential_expression_inner_tag
138
+ elsif expression_content?
139
+ self.curr_expr << scanner.matched
140
+ else
141
+ raise SyntaxError, self
142
+ end
143
+ when :expression_inner_bracket
144
+ if scanner.scan(PATTERNS[:close_expression])
145
+ self.curr_expr << scanner.matched
146
+ stack.pop
147
+ elsif scanner.scan(PATTERNS[:open_expression])
148
+ expression_inner_bracket
149
+ elsif scanner.scan(PATTERNS[:double_quote])
150
+ expression_inner_double_quote
151
+ elsif scanner.scan(PATTERNS[:single_quote])
152
+ expression_inner_single_quote
153
+ elsif scanner.scan(PATTERNS[:open_tag_def])
154
+ potential_expression_inner_tag
155
+ elsif expression_content?
156
+ self.curr_expr << scanner.matched
157
+ else
158
+ raise SyntaxError, self
159
+ end
160
+ when :expression_inner_double_quote
161
+ if scanner.check(PATTERNS[:double_quote])
162
+ expression_quoted_string_content
163
+ elsif scanner.scan(PATTERNS[:double_quoted_text_content])
164
+ self.curr_expr << scanner.matched
165
+ else
166
+ raise SyntaxError, self
167
+ end
168
+ when :expression_inner_single_quote
169
+ if scanner.check(PATTERNS[:single_quote])
170
+ expression_quoted_string_content
171
+ elsif scanner.scan(PATTERNS[:single_quoted_text_content])
172
+ self.curr_expr << scanner.matched
173
+ else
174
+ raise SyntaxError, self
175
+ end
176
+ when :tag_def
177
+ if scanner.scan(PATTERNS[:close_self_closing_tag])
178
+ tokens << [:CLOSE_TAG_DEF]
179
+ tokens << [:OPEN_TAG_END]
180
+ tokens << [:CLOSE_TAG_END]
181
+ stack.pop(2)
182
+ elsif scanner.scan(PATTERNS[:close_tag])
183
+ tokens << [:CLOSE_TAG_DEF]
184
+ stack.pop
185
+ elsif scanner.scan(PATTERNS[:tag_name])
186
+ tokens << [:TAG_DETAILS, tag_details(scanner.matched)]
187
+ elsif scanner.scan(PATTERNS[:whitespace])
188
+ scanner.matched.count("\n").times { tokens << [:NEWLINE] }
189
+ tokens << [:OPEN_ATTRS]
190
+ stack.push(:tag_attrs)
191
+ else
192
+ raise SyntaxError, self
193
+ end
194
+ when :tag_end
195
+ if scanner.scan(PATTERNS[:close_tag])
196
+ tokens << [:CLOSE_TAG_END]
197
+ stack.pop(2)
198
+ elsif scanner.scan(PATTERNS[:tag_name])
199
+ tokens << [:TAG_NAME, scanner.matched]
200
+ else
201
+ raise SyntaxError, self
202
+ end
203
+ when :tag_attrs
204
+ if scanner.scan(PATTERNS[:whitespace])
205
+ scanner.matched.count("\n").times { tokens << [:NEWLINE] }
206
+ elsif scanner.check(PATTERNS[:close_tag])
207
+ tokens << [:CLOSE_ATTRS]
208
+ stack.pop
209
+ elsif scanner.scan(PATTERNS[:attr_assignment])
210
+ tokens << [:OPEN_ATTR_VALUE]
211
+ stack.push(:tag_attr_value)
212
+ elsif scanner.scan(PATTERNS[:attr])
213
+ tokens << [:ATTR_NAME, scanner.matched.strip]
214
+ elsif scanner.scan(PATTERNS[:open_attr_splat])
215
+ tokens << [:OPEN_ATTR_SPLAT]
216
+ tokens << [:OPEN_EXPRESSION]
217
+ stack.push(:tag_attr_splat, :expression)
218
+ else
219
+ raise SyntaxError, self
220
+ end
221
+ when :tag_attr_value
222
+ if scanner.scan(PATTERNS[:double_quote])
223
+ stack.push(:quoted_text)
224
+ elsif scanner.scan(PATTERNS[:open_expression])
225
+ open_expression
226
+ elsif scanner.scan(PATTERNS[:whitespace]) || scanner.check(PATTERNS[:close_tag])
227
+ tokens << [:CLOSE_ATTR_VALUE]
228
+ scanner.matched.count("\n").times { tokens << [:NEWLINE] }
229
+ stack.pop
230
+ else
231
+ raise SyntaxError, self
232
+ end
233
+ when :tag_attr_splat
234
+ # Splat is consumed by :expression. It pops control back to here once
235
+ # it's done, and we just record the completion and pop back to :tag_attrs
236
+ tokens << [:CLOSE_ATTR_SPLAT]
237
+ stack.pop
238
+ when :quoted_text
239
+ if scanner.scan(PATTERNS[:double_quoted_text_content])
240
+ self.curr_quoted_text << scanner.matched
241
+ if scanner.matched.end_with?('\\') && scanner.peek(1) == "\""
242
+ self.curr_quoted_text << scanner.getch
243
+ end
244
+ elsif scanner.scan(PATTERNS[:double_quote])
245
+ tokens << [:TEXT, curr_quoted_text]
246
+ self.curr_quoted_text = ""
247
+ stack.pop
248
+ else
249
+ raise SyntaxError, self
250
+ end
251
+ else
252
+ raise SyntaxError, self
253
+ end
254
+ end
255
+
256
+ tokens
257
+ end
258
+
259
+ def potential_expression_inner_tag
260
+ if self.curr_expr =~ PATTERNS[:expression_internal_tag_prefixes]
261
+ tokens << [:EXPRESSION_BODY, curr_expr]
262
+ self.curr_expr = ""
263
+ open_tag_def
264
+ else
265
+ self.curr_expr << scanner.matched
266
+ end
267
+ end
268
+
269
+ def open_tag_def
270
+ # Lookahead: if the scanner is immediately at `>` (with optional leading whitespace)
271
+ # then this is a JSX fragment opener `<>` — emit OPEN_FRAGMENT and stay in current context.
272
+ if scanner.check(/\s*>/)
273
+ scanner.scan(/\s*>/)
274
+ tokens << [:OPEN_FRAGMENT]
275
+ stack.push(:tag) # enter :tag context so children are parsed, fragment close handled above
276
+ else
277
+ tokens << [:OPEN_TAG_DEF]
278
+ stack.push(:tag, :tag_def)
279
+ end
280
+ end
281
+
282
+ def open_expression
283
+ tokens << [:OPEN_EXPRESSION]
284
+ stack.push(:expression)
285
+ end
286
+
287
+ def expression_inner_bracket
288
+ self.curr_expr << scanner.matched
289
+ stack.push(:expression_inner_bracket)
290
+ end
291
+
292
+ def expression_inner_double_quote
293
+ self.curr_expr << scanner.matched
294
+ stack.push(:expression_inner_double_quote)
295
+ end
296
+
297
+ def expression_inner_single_quote
298
+ self.curr_expr << scanner.matched
299
+ stack.push(:expression_inner_single_quote)
300
+ end
301
+
302
+ def expression_quoted_string_content
303
+ self.curr_expr << scanner.getch
304
+ stack.pop unless curr_expr.end_with?('\\')
305
+ end
306
+
307
+ def expression_content?
308
+ # PATTERNS[:expression_content] ends at `<` characters, because we need to
309
+ # separately scan for allowed open_tag_defs within expressions. We should
310
+ # support any found open_tag_ends as expression content, as that means the
311
+ # open_tag_def was not considered allowed (or stack would be inside
312
+ # :tag_def instead of :expression) so we should thus also consider the
313
+ # open_tag_end to just be a part of the expression (maybe its in a string,
314
+ # etc).
315
+ scanner.scan(PATTERNS[:expression_content]) || scanner.scan(PATTERNS[:open_tag_end])
316
+ end
317
+
318
+ def tag_details(name)
319
+ type = element_resolver.component?(name, template) ? :component : :html
320
+ details = { name: scanner.matched, type: type }
321
+ details[:component_class] = element_resolver.component_class(name, template) if type == :component
322
+ details
323
+ end
324
+ end
325
+ end
@@ -0,0 +1,12 @@
1
+ module Grsx
2
+ module Nodes
3
+ class AbstractAttr < AbstractNode
4
+ attr_accessor :name, :value
5
+
6
+ def initialize(name, value)
7
+ @name = name
8
+ @value = value
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ module Grsx
2
+ module Nodes
3
+ class AbstractElement < AbstractNode
4
+ attr_accessor :name, :members, :children
5
+
6
+ def initialize(name, members, children)
7
+ @name = name
8
+ @members = members || []
9
+ @children = children
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,31 @@
1
+ module Grsx
2
+ module Nodes
3
+ class AbstractNode
4
+ # Compact adjacent Raw/Newline nodes into a single Raw node.
5
+ # Used by the parser when building the AST.
6
+ private
7
+
8
+ def compact(nodes)
9
+ compacted = []
10
+ curr_raw = nil
11
+
12
+ nodes.each do |node|
13
+ if node.is_a?(Newline) && curr_raw
14
+ curr_raw.merge(Raw.new("\n"))
15
+ elsif node.is_a?(Raw)
16
+ if !curr_raw
17
+ curr_raw ||= Raw.new("")
18
+ compacted << curr_raw
19
+ end
20
+ curr_raw.merge(node)
21
+ else
22
+ curr_raw = nil
23
+ compacted << node
24
+ end
25
+ end
26
+
27
+ compacted
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,69 @@
1
+ module Grsx
2
+ module Nodes
3
+ class ComponentElement < AbstractElement
4
+ attr_reader :template
5
+
6
+ OUTPUT = "@output_buffer.safe_concat(%s);"
7
+ EXPR_STRING = "%s.html_safe"
8
+
9
+ def initialize(*args, template: OUTPUT)
10
+ super(*args)
11
+ @template = template
12
+ end
13
+
14
+ def precompile
15
+ [ComponentElement.new(name, precompile_members, precompile_children)]
16
+ end
17
+
18
+ def compile
19
+ templates = Grsx.configuration.component_rendering_templates
20
+
21
+ tag = templates[:component] % {
22
+ component_class: name,
23
+ view_context: "self",
24
+ kwargs: compile_members,
25
+ children_block: children.any? ? templates[:children] % { children: children.map(&:compile).join } : ""
26
+ }
27
+
28
+ if Grsx.configuration.enable_context
29
+ tag = "(grsx_context.push({});#{tag}.tap{grsx_context.pop})"
30
+ end
31
+
32
+ template % tag
33
+ end
34
+
35
+ def compile_members
36
+ members.each_with_object("") do |member, result|
37
+ case member
38
+ when ExpressionGroup
39
+ result << "**#{member.compile}.transform_keys { |k| ActiveSupport::Inflector.underscore(k).to_sym },"
40
+ when Newline
41
+ result << member.compile
42
+ else
43
+ result << "#{member.compile},"
44
+ end
45
+ end.gsub(/,\z/, "")
46
+ end
47
+
48
+ private
49
+
50
+ def precompile_members
51
+ members.map do |node|
52
+ if node.is_a? ExpressionGroup
53
+ ExpressionGroup.new(
54
+ node.members,
55
+ inner_template: ExpressionGroup::SUB_EXPR,
56
+ outer_template: ExpressionGroup::SUB_EXPR
57
+ )
58
+ else
59
+ node
60
+ end
61
+ end.map(&:precompile).flatten
62
+ end
63
+
64
+ def precompile_children
65
+ compact(children.map(&:precompile).flatten)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,29 @@
1
+ module Grsx
2
+ module Nodes
3
+ class ComponentProp < AbstractAttr
4
+ def precompile
5
+ [ComponentProp.new(name, precompile_value)]
6
+ end
7
+
8
+ def compile
9
+ key = ActiveSupport::Inflector.underscore(name)
10
+ "#{key}: #{value.compile}"
11
+ end
12
+
13
+ private
14
+
15
+ def precompile_value
16
+ node = value.precompile.first
17
+
18
+ case node
19
+ when Raw
20
+ Raw.new(node.content, template: Raw::EXPR_STRING)
21
+ when ExpressionGroup
22
+ ExpressionGroup.new(node.members, outer_template: ExpressionGroup::SUB_EXPR, inner_template: node.inner_template)
23
+ else
24
+ node
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,15 @@
1
+ module Grsx
2
+ module Nodes
3
+ class Declaration < AbstractNode
4
+ attr_accessor :content
5
+
6
+ def initialize(content)
7
+ @content = content
8
+ end
9
+
10
+ def precompile
11
+ [Raw.new(Util.escape_string(content))]
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Grsx
2
+ module Nodes
3
+ class Expression < AbstractNode
4
+ attr_accessor :content
5
+
6
+ def initialize(content)
7
+ @content = content
8
+ end
9
+
10
+ def compile
11
+ content
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Grsx
2
+ module Nodes
3
+ class ExpressionGroup < AbstractNode
4
+ attr_accessor :members
5
+ attr_reader :outer_template, :inner_template
6
+
7
+ def initialize(members, outer_template: nil, inner_template: nil)
8
+ @members = members
9
+ @outer_template = outer_template
10
+ @inner_template = inner_template
11
+ end
12
+ end
13
+ end
14
+ end
15
+
@@ -0,0 +1,30 @@
1
+ module Grsx
2
+ module Nodes
3
+ # A Fragment node renders its children without a wrapping element.
4
+ # It corresponds to the JSX <></> syntax.
5
+ #
6
+ # In JSX:
7
+ # <>
8
+ # <h1>Title</h1>
9
+ # <p>Body</p>
10
+ # </>
11
+ #
12
+ # Compiled Phlex DSL: just the children inline, no wrapper.
13
+ class Fragment < AbstractNode
14
+ attr_accessor :children
15
+
16
+ def initialize(children)
17
+ @children = children
18
+ end
19
+
20
+ # ActionView codegen: compile children directly, no wrapper tag
21
+ def precompile
22
+ children.map(&:precompile).flatten
23
+ end
24
+
25
+ def compile
26
+ children.map(&:compile).join
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,13 @@
1
+ module Grsx
2
+ module Nodes
3
+ class HTMLAttr < AbstractAttr
4
+ def precompile
5
+ [
6
+ Raw.new(" #{name}=\""),
7
+ value.precompile,
8
+ Raw.new("\"")
9
+ ].flatten
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,49 @@
1
+ module Grsx
2
+ module Nodes
3
+ class HTMLElement < AbstractElement
4
+ # Referenced from https://html.spec.whatwg.org/#void-elements
5
+ HTML_VOID_ELEMENTS = %w(area base br col embed hr img input link meta source track wbr)
6
+
7
+ def precompile
8
+ nodes = []
9
+
10
+ if void? && children.length == 0
11
+ nodes.concat(precompile_open_tag)
12
+ else
13
+ nodes.concat(precompile_open_tag)
14
+ nodes.concat(children.map(&:precompile).flatten)
15
+ nodes << Raw.new("</#{name}>")
16
+ end
17
+
18
+ nodes
19
+ end
20
+
21
+ private
22
+
23
+ def void?
24
+ HTML_VOID_ELEMENTS.include?(name)
25
+ end
26
+
27
+ def precompile_open_tag
28
+ nodes = [Raw.new("<#{name}")]
29
+ nodes.concat(precompile_members)
30
+ nodes << Raw.new(">")
31
+ nodes
32
+ end
33
+
34
+ def precompile_members
35
+ members.map do |node|
36
+ if node.is_a? ExpressionGroup
37
+ ExpressionGroup.new(
38
+ node.members,
39
+ inner_template: "Grsx::Runtime.splat_attrs(%s)",
40
+ outer_template: ExpressionGroup::OUTPUT_SAFE
41
+ )
42
+ else
43
+ node
44
+ end
45
+ end.map(&:precompile).flatten
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,9 @@
1
+ module Grsx
2
+ module Nodes
3
+ class Newline < AbstractNode
4
+ def compile
5
+ "\n"
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,23 @@
1
+ module Grsx
2
+ module Nodes
3
+ class Raw < AbstractNode
4
+ attr_reader :content, :template
5
+
6
+ OUTPUT = "@output_buffer.safe_concat('%s'.freeze);"
7
+ EXPR_STRING = "'%s'.html_safe.freeze"
8
+
9
+ def initialize(content, template: OUTPUT)
10
+ @content = content
11
+ @template = template
12
+ end
13
+
14
+ def compile
15
+ template % content
16
+ end
17
+
18
+ def merge(other_raw)
19
+ content << other_raw.content
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,19 @@
1
+ module Grsx
2
+ module Nodes
3
+ class Root < AbstractNode
4
+ attr_accessor :children
5
+
6
+ def initialize(children)
7
+ @children = children
8
+ end
9
+
10
+ def precompile
11
+ Root.new(compact(children.map(&:precompile).flatten))
12
+ end
13
+
14
+ def compile
15
+ "#{children.map(&:compile).join}@output_buffer.to_s"
16
+ end
17
+ end
18
+ end
19
+ end