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.
- checksums.yaml +7 -0
- data/.github/workflows/main.yml +20 -0
- data/.gitignore +5 -0
- data/.rspec +2 -0
- data/.travis.yml +6 -0
- data/Appraisals +17 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Dockerfile +8 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +274 -0
- data/Guardfile +70 -0
- data/LICENSE.txt +21 -0
- data/README.md +437 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/bin/test +43 -0
- data/docker-compose.yml +29 -0
- data/gemfiles/.bundle/config +2 -0
- data/gemfiles/rails_6_1.gemfile +8 -0
- data/gemfiles/rails_6_1.gemfile.lock +260 -0
- data/gemfiles/rails_7_0.gemfile +7 -0
- data/gemfiles/rails_7_0.gemfile.lock +265 -0
- data/gemfiles/rails_7_1.gemfile +7 -0
- data/gemfiles/rails_7_1.gemfile.lock +295 -0
- data/gemfiles/rails_7_2.gemfile +7 -0
- data/gemfiles/rails_7_2.gemfile.lock +290 -0
- data/gemfiles/rails_8_0.gemfile +8 -0
- data/gemfiles/rails_8_0.gemfile.lock +344 -0
- data/gemfiles/rails_8_1.gemfile +8 -0
- data/gemfiles/rails_8_1.gemfile.lock +313 -0
- data/gemfiles/rails_master.gemfile +7 -0
- data/gemfiles/rails_master.gemfile.lock +296 -0
- data/grsx.gemspec +43 -0
- data/lib/generators/grsx/phlex_component/phlex_component_generator.rb +40 -0
- data/lib/generators/grsx/phlex_component/templates/component.rb.tt +12 -0
- data/lib/generators/grsx/phlex_component/templates/component.rbx.tt +6 -0
- data/lib/grsx/component_resolver.rb +64 -0
- data/lib/grsx/configuration.rb +14 -0
- data/lib/grsx/lexer.rb +325 -0
- data/lib/grsx/nodes/abstract_attr.rb +12 -0
- data/lib/grsx/nodes/abstract_element.rb +13 -0
- data/lib/grsx/nodes/abstract_node.rb +31 -0
- data/lib/grsx/nodes/component_element.rb +69 -0
- data/lib/grsx/nodes/component_prop.rb +29 -0
- data/lib/grsx/nodes/declaration.rb +15 -0
- data/lib/grsx/nodes/expression.rb +15 -0
- data/lib/grsx/nodes/expression_group.rb +15 -0
- data/lib/grsx/nodes/fragment.rb +30 -0
- data/lib/grsx/nodes/html_attr.rb +13 -0
- data/lib/grsx/nodes/html_element.rb +49 -0
- data/lib/grsx/nodes/newline.rb +9 -0
- data/lib/grsx/nodes/raw.rb +23 -0
- data/lib/grsx/nodes/root.rb +19 -0
- data/lib/grsx/nodes/text.rb +15 -0
- data/lib/grsx/nodes/util.rb +9 -0
- data/lib/grsx/nodes.rb +20 -0
- data/lib/grsx/parser.rb +238 -0
- data/lib/grsx/phlex_compiler.rb +223 -0
- data/lib/grsx/phlex_component.rb +361 -0
- data/lib/grsx/phlex_runtime.rb +70 -0
- data/lib/grsx/prop_inspector.rb +52 -0
- data/lib/grsx/rails/engine.rb +24 -0
- data/lib/grsx/rails/phlex_reloader.rb +25 -0
- data/lib/grsx/template.rb +12 -0
- data/lib/grsx/version.rb +3 -0
- data/lib/grsx.rb +35 -0
- metadata +324 -0
data/lib/grsx/nodes.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Grsx
|
|
2
|
+
module Nodes
|
|
3
|
+
autoload :Util, "grsx/nodes/util"
|
|
4
|
+
autoload :AbstractNode, "grsx/nodes/abstract_node"
|
|
5
|
+
autoload :Root, "grsx/nodes/root"
|
|
6
|
+
autoload :Raw, "grsx/nodes/raw"
|
|
7
|
+
autoload :Text, "grsx/nodes/text"
|
|
8
|
+
autoload :ExpressionGroup, "grsx/nodes/expression_group"
|
|
9
|
+
autoload :Expression, "grsx/nodes/expression"
|
|
10
|
+
autoload :AbstractElement, "grsx/nodes/abstract_element"
|
|
11
|
+
autoload :HTMLElement, "grsx/nodes/html_element"
|
|
12
|
+
autoload :ComponentElement, "grsx/nodes/component_element"
|
|
13
|
+
autoload :AbstractAttr, "grsx/nodes/abstract_attr"
|
|
14
|
+
autoload :HTMLAttr, "grsx/nodes/html_attr"
|
|
15
|
+
autoload :ComponentProp, "grsx/nodes/component_prop"
|
|
16
|
+
autoload :Newline, "grsx/nodes/newline"
|
|
17
|
+
autoload :Declaration, "grsx/nodes/declaration"
|
|
18
|
+
autoload :Fragment, "grsx/nodes/fragment"
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/grsx/parser.rb
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
module Grsx
|
|
2
|
+
class Parser
|
|
3
|
+
class ParseError < StandardError
|
|
4
|
+
attr_reader :position
|
|
5
|
+
|
|
6
|
+
def initialize(message = nil, position: nil)
|
|
7
|
+
@position = position
|
|
8
|
+
msg = message || "Unexpected token"
|
|
9
|
+
msg = "#{msg} at token #{position}" if position
|
|
10
|
+
super(msg)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
attr_reader :tokens
|
|
15
|
+
attr_accessor :position
|
|
16
|
+
|
|
17
|
+
def initialize(tokens)
|
|
18
|
+
@tokens = tokens
|
|
19
|
+
@position = 0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def parse
|
|
23
|
+
validate_tokens!
|
|
24
|
+
Nodes::Root.new(parse_tokens)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def parse_tokens
|
|
28
|
+
results = []
|
|
29
|
+
|
|
30
|
+
while result = parse_token
|
|
31
|
+
results << result
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
results
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def parse_token
|
|
38
|
+
parse_text || parse_newline || parse_expression || parse_fragment || parse_tag || parse_declaration
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def parse_text
|
|
42
|
+
return unless token = take(:TEXT)
|
|
43
|
+
Nodes::Text.new(token[1])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parse_expression
|
|
47
|
+
return unless take(:OPEN_EXPRESSION)
|
|
48
|
+
|
|
49
|
+
members = []
|
|
50
|
+
|
|
51
|
+
eventually!(:CLOSE_EXPRESSION)
|
|
52
|
+
until take(:CLOSE_EXPRESSION)
|
|
53
|
+
members << (parse_expression_body || parse_tag)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Nodes::ExpressionGroup.new(members)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def parse_expression!
|
|
60
|
+
peek!(:OPEN_EXPRESSION)
|
|
61
|
+
parse_expression
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def parse_expression_body
|
|
65
|
+
return unless token = take(:EXPRESSION_BODY)
|
|
66
|
+
Nodes::Expression.new(token[1])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def parse_fragment
|
|
70
|
+
return unless take(:OPEN_FRAGMENT)
|
|
71
|
+
|
|
72
|
+
children = []
|
|
73
|
+
until take(:CLOSE_FRAGMENT)
|
|
74
|
+
children << parse_token
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
Nodes::Fragment.new(children)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def parse_tag
|
|
81
|
+
return unless take(:OPEN_TAG_DEF)
|
|
82
|
+
|
|
83
|
+
details = take!(:TAG_DETAILS)[1]
|
|
84
|
+
attr_class = details[:type] == :component ? Nodes::ComponentProp : Nodes::HTMLAttr
|
|
85
|
+
|
|
86
|
+
members = []
|
|
87
|
+
members.concat(take_all(:NEWLINE).map { Nodes::Newline.new })
|
|
88
|
+
members.concat(parse_attrs(attr_class))
|
|
89
|
+
|
|
90
|
+
take!(:CLOSE_TAG_DEF)
|
|
91
|
+
|
|
92
|
+
children = parse_children(details[:name])
|
|
93
|
+
|
|
94
|
+
if details[:type] == :component
|
|
95
|
+
Nodes::ComponentElement.new(details[:component_class], members, children)
|
|
96
|
+
else
|
|
97
|
+
Nodes::HTMLElement.new(details[:name], members, children)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def parse_attrs(attr_class)
|
|
102
|
+
return [] unless take(:OPEN_ATTRS)
|
|
103
|
+
|
|
104
|
+
attrs = []
|
|
105
|
+
|
|
106
|
+
eventually!(:CLOSE_ATTRS)
|
|
107
|
+
until take(:CLOSE_ATTRS)
|
|
108
|
+
attrs << (parse_splat_attr || parse_newline || parse_attr(attr_class))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
attrs
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def parse_splat_attr
|
|
115
|
+
return unless take(:OPEN_ATTR_SPLAT)
|
|
116
|
+
|
|
117
|
+
expression = parse_expression!
|
|
118
|
+
take!(:CLOSE_ATTR_SPLAT)
|
|
119
|
+
|
|
120
|
+
expression
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def parse_newline
|
|
124
|
+
return unless take(:NEWLINE)
|
|
125
|
+
Nodes::Newline.new
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def parse_attr(attr_class)
|
|
129
|
+
name = take!(:ATTR_NAME)[1]
|
|
130
|
+
value = nil
|
|
131
|
+
|
|
132
|
+
if take(:OPEN_ATTR_VALUE)
|
|
133
|
+
value = parse_text || parse_expression
|
|
134
|
+
raise ParseError, "Missing attribute value" unless value
|
|
135
|
+
take(:CLOSE_ATTR_VALUE)
|
|
136
|
+
else
|
|
137
|
+
value = default_empty_attr_value
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
attr_class.new(name, value)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def parse_children(expected_name)
|
|
144
|
+
children = []
|
|
145
|
+
|
|
146
|
+
eventually!(:OPEN_TAG_END)
|
|
147
|
+
until take(:OPEN_TAG_END)
|
|
148
|
+
children << parse_token
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
if tag_name_token = take(:TAG_NAME)
|
|
152
|
+
closing_name = tag_name_token[1]
|
|
153
|
+
if closing_name != expected_name
|
|
154
|
+
raise ParseError, "Mismatched tag: expected </#{expected_name}> but got </#{closing_name}>"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
take!(:CLOSE_TAG_END)
|
|
159
|
+
|
|
160
|
+
children
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def parse_declaration
|
|
166
|
+
return unless token = take(:DECLARATION)
|
|
167
|
+
Nodes::Declaration.new(token[1])
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def take(token_name)
|
|
171
|
+
if token = peek(token_name)
|
|
172
|
+
self.position += 1
|
|
173
|
+
token
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def take_all(token_name)
|
|
178
|
+
result = []
|
|
179
|
+
while token = take(token_name)
|
|
180
|
+
result << token
|
|
181
|
+
end
|
|
182
|
+
result
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def take!(token_name)
|
|
186
|
+
take(token_name) || unexpected_token!(token_name)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def peek(token_name)
|
|
190
|
+
if (token = tokens[position]) && token[0] == token_name
|
|
191
|
+
token
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def peek!(token_name)
|
|
196
|
+
peek(token_name) || unexpected_token!(token_name)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def eventually!(token_name)
|
|
200
|
+
tokens[position..-1].first { |t| t[0] == token_name } ||
|
|
201
|
+
raise(ParseError, "Expected to find a #{token_name} but never did")
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def default_empty_attr_value
|
|
205
|
+
Nodes::Text.new("")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def error_window
|
|
209
|
+
window_start = position - 2
|
|
210
|
+
window_start = 0 if window_start < 0
|
|
211
|
+
window_end = position + 2
|
|
212
|
+
window_end = tokens.length-1 if window_end >= tokens.length
|
|
213
|
+
|
|
214
|
+
tokens[window_start..window_end].map.with_index do |token, i|
|
|
215
|
+
prefix = window_start + i == position ? "=>" : " "
|
|
216
|
+
"#{prefix} #{token}"
|
|
217
|
+
end.join("\n")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def unexpected_token!(expected_token)
|
|
221
|
+
raise(ParseError, "Unexpected token #{tokens[position][0]}, expecting #{expected_token}\n#{error_window}")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def validate_tokens!
|
|
225
|
+
validate_all_tags_close!
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def validate_all_tags_close!
|
|
229
|
+
open_count = tokens.count { |t| t[0] == :OPEN_TAG_DEF } +
|
|
230
|
+
tokens.count { |t| t[0] == :OPEN_FRAGMENT }
|
|
231
|
+
close_count = tokens.count { |t| t[0] == :OPEN_TAG_END } +
|
|
232
|
+
tokens.count { |t| t[0] == :CLOSE_FRAGMENT }
|
|
233
|
+
if open_count != close_count
|
|
234
|
+
raise(ParseError, "#{open_count - close_count} tags fail to close. All tags must close, either <NAME></NAME>, self-closing <NAME />, or <>...</>")
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
module Grsx
|
|
2
|
+
# Converts a Grsx AST into a Ruby string of Phlex DSL method calls.
|
|
3
|
+
#
|
|
4
|
+
# The emitted code is suitable for evaluation inside a Grsx::PhlexComponent
|
|
5
|
+
# (or Grsx::PhlexRuntime for standalone use). All output goes through
|
|
6
|
+
# Phlex's structural buffer — no ActionView @output_buffer.
|
|
7
|
+
#
|
|
8
|
+
# Key mappings from JSX → Phlex DSL:
|
|
9
|
+
#
|
|
10
|
+
# <div class="foo">text</div> → div(class: "foo") { plain("text") }
|
|
11
|
+
# <br /> → br
|
|
12
|
+
# <Button label={x} /> → render(ButtonComponent.new(label: x))
|
|
13
|
+
# <Card>{content}</Card> → render(Card.new) { yield_content }
|
|
14
|
+
# {"Hello"} → plain("Hello") (CGI.escapeHTML called)
|
|
15
|
+
# {render MyComp.new} → render(MyComp.new) (structural, shared buffer)
|
|
16
|
+
# {content} → yield_content (slot pattern in components)
|
|
17
|
+
#
|
|
18
|
+
class PhlexCompiler
|
|
19
|
+
HTML_VOID_ELEMENTS = %w(area base br col embed hr img input link meta source track wbr).to_set
|
|
20
|
+
|
|
21
|
+
attr_reader :root
|
|
22
|
+
|
|
23
|
+
def initialize(root)
|
|
24
|
+
@root = root
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def compile
|
|
28
|
+
compile_nodes(root.children)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def compile_nodes(nodes)
|
|
34
|
+
nodes.map { |n| compile_node(n) }.join("\n")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def compile_node(node)
|
|
38
|
+
case node
|
|
39
|
+
when Nodes::HTMLElement then compile_html(node)
|
|
40
|
+
when Nodes::ComponentElement then compile_component(node)
|
|
41
|
+
when Nodes::Fragment then compile_fragment(node)
|
|
42
|
+
when Nodes::ExpressionGroup then compile_expression_group(node)
|
|
43
|
+
when Nodes::Text then compile_text(node)
|
|
44
|
+
when Nodes::Newline then ""
|
|
45
|
+
when Nodes::Declaration then compile_declaration(node)
|
|
46
|
+
else
|
|
47
|
+
raise "PhlexCompiler: unknown AST node #{node.class}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# <></> → children rendered inline, no wrapper element.
|
|
52
|
+
# The JSX fragment pattern eliminates the need for meaningless <div> wrappers.
|
|
53
|
+
def compile_fragment(node)
|
|
54
|
+
compile_nodes(node.children)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# <div class="foo"> ... </div>
|
|
58
|
+
# → div(class: "foo") do ... end
|
|
59
|
+
def compile_html(node)
|
|
60
|
+
tag_name = node.name
|
|
61
|
+
attrs = compile_html_attrs(node.members)
|
|
62
|
+
|
|
63
|
+
if HTML_VOID_ELEMENTS.include?(tag_name)
|
|
64
|
+
# void: <br /> → br
|
|
65
|
+
attrs.empty? ? tag_name : "#{tag_name}(#{attrs})"
|
|
66
|
+
elsif node.children.empty?
|
|
67
|
+
# no children: <span class="x" /> → span(class: "x") {}
|
|
68
|
+
attrs.empty? ? "#{tag_name} {}" : "#{tag_name}(#{attrs}) {}"
|
|
69
|
+
else
|
|
70
|
+
inner = compile_nodes(node.children)
|
|
71
|
+
if attrs.empty?
|
|
72
|
+
"#{tag_name} do\n#{inner}\nend"
|
|
73
|
+
else
|
|
74
|
+
"#{tag_name}(#{attrs}) do\n#{inner}\nend"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Build keyword argument string from HTMLAttr and ExpressionGroup (splat) members
|
|
80
|
+
def compile_html_attrs(members)
|
|
81
|
+
parts = []
|
|
82
|
+
members.each do |m|
|
|
83
|
+
case m
|
|
84
|
+
when Nodes::HTMLAttr
|
|
85
|
+
key = normalize_html_attr_key(m.name)
|
|
86
|
+
val = compile_attr_value(m.value)
|
|
87
|
+
parts << "#{key}: #{val}"
|
|
88
|
+
when Nodes::ExpressionGroup
|
|
89
|
+
# {**spread_hash} → spread into tag kwargs
|
|
90
|
+
parts << "**#{compile_expression_group_value(m)}"
|
|
91
|
+
when Nodes::Newline
|
|
92
|
+
# ignore
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
parts.join(", ")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def compile_attr_value(node)
|
|
99
|
+
case node
|
|
100
|
+
when Nodes::Text
|
|
101
|
+
# A boolean (bare) attribute like `disabled` or `required` has an empty
|
|
102
|
+
# text value in the AST. Phlex emits the attribute name alone for `true`
|
|
103
|
+
# and omits it completely for `false`. Empty string would produce `attr=""`
|
|
104
|
+
# which is technically valid HTML but semantically wrong for boolean attrs.
|
|
105
|
+
node.content.empty? ? "true" : node.content.inspect
|
|
106
|
+
when Nodes::ExpressionGroup then compile_expression_group_value(node)
|
|
107
|
+
else node.class.name
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# <Button label={@title} disabled /> — key prop is silently dropped
|
|
112
|
+
# → render ButtonComponent.new(label: @title, disabled: "")
|
|
113
|
+
def compile_component(node)
|
|
114
|
+
kwargs_parts = []
|
|
115
|
+
node.members.each do |m|
|
|
116
|
+
case m
|
|
117
|
+
when Nodes::ComponentProp
|
|
118
|
+
# Strip the React `key` prop — it has no server-side meaning and
|
|
119
|
+
# would otherwise cause an ArgumentError on the Ruby component.
|
|
120
|
+
next if m.name == "key"
|
|
121
|
+
|
|
122
|
+
key = normalize_component_prop_key(m.name)
|
|
123
|
+
val = compile_attr_value(m.value)
|
|
124
|
+
kwargs_parts << "#{key}: #{val}"
|
|
125
|
+
when Nodes::ExpressionGroup
|
|
126
|
+
kwargs_parts << "**#{compile_expression_group_value(m)}"
|
|
127
|
+
when Nodes::Newline then next
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
kwargs = kwargs_parts.join(", ")
|
|
132
|
+
component_expr = kwargs.empty? ? "::#{node.name}.new" : "::#{node.name}.new(#{kwargs})"
|
|
133
|
+
|
|
134
|
+
if node.children.any?
|
|
135
|
+
inner = compile_nodes(node.children)
|
|
136
|
+
"render(#{component_expr}) {\n#{inner}\n}"
|
|
137
|
+
else
|
|
138
|
+
"render(#{component_expr})"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# { ruby_expr } in text position:
|
|
143
|
+
#
|
|
144
|
+
# {content} → yield_content (children slot)
|
|
145
|
+
# {"Hello"} → __rbx_expr_out("Hello") → plain() with auto-escape
|
|
146
|
+
# {render Foo.new} → __rbx_expr_out(render Foo.new)
|
|
147
|
+
def compile_expression_group(node)
|
|
148
|
+
# Special case: bare `content` identifier → forward children via Phlex yield
|
|
149
|
+
if content_call?(node)
|
|
150
|
+
return "yield"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
expr = compile_expression_group_value(node)
|
|
154
|
+
"__rbx_expr_out(#{expr})"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Returns true when the expression group is a bare `content` identifier.
|
|
158
|
+
# This is the JSX children-slot pattern: {content}
|
|
159
|
+
def content_call?(node)
|
|
160
|
+
node.members.length == 1 &&
|
|
161
|
+
node.members.first.is_a?(Nodes::Expression) &&
|
|
162
|
+
node.members.first.content.strip == "content"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Returns the raw ruby expression string (no output call wrap)
|
|
166
|
+
def compile_expression_group_value(node)
|
|
167
|
+
node.members.map { |m| compile_expression_value(m) }.join
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def compile_expression_value(node)
|
|
171
|
+
case node
|
|
172
|
+
when Nodes::Expression then node.content
|
|
173
|
+
when Nodes::HTMLElement then compile_html(node)
|
|
174
|
+
when Nodes::ComponentElement then compile_component(node)
|
|
175
|
+
when Nodes::ExpressionGroup then "(#{compile_expression_group_value(node)})"
|
|
176
|
+
else node.class.name
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Static text: "Hello" → plain("Hello")
|
|
181
|
+
def compile_text(node)
|
|
182
|
+
"plain(#{node.content.inspect})"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# <!DOCTYPE html> → raw(safe("<!DOCTYPE html>"))
|
|
186
|
+
def compile_declaration(node)
|
|
187
|
+
"raw(safe(#{node.content.inspect}))"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Normalize HTML attribute names to Ruby keyword argument keys.
|
|
191
|
+
#
|
|
192
|
+
# One rule: kebab-case → snake_case (the only transformation needed
|
|
193
|
+
# to make HTML attribute names legal Ruby keyword arguments).
|
|
194
|
+
#
|
|
195
|
+
# class → class (Phlex accepts `class:` just fine)
|
|
196
|
+
# data-controller → data_controller (Phlex re-emits as data-controller)
|
|
197
|
+
# aria-label → aria_label (Phlex re-emits as aria-label)
|
|
198
|
+
# for → for (no htmlFor nonsense)
|
|
199
|
+
# id, src, href → unchanged
|
|
200
|
+
#
|
|
201
|
+
# We do NOT do camelCase → snake_case. That's a React-ism: React uses
|
|
202
|
+
# camelCase because JSX is JavaScript where `class` / `for` are reserved.
|
|
203
|
+
# In rbx we're in Ruby; write HTML attributes as HTML writes them.
|
|
204
|
+
def normalize_html_attr_key(name)
|
|
205
|
+
name.tr("-", "_")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Normalize component prop names to Ruby keyword argument keys.
|
|
209
|
+
#
|
|
210
|
+
# Component props map directly to Ruby kwargs, so we accept both
|
|
211
|
+
# HTML-style kebab-case and Ruby snake_case:
|
|
212
|
+
# card-title → card_title (kebab, HTML-like)
|
|
213
|
+
# card_title → card_title (snake, Ruby-like, unchanged)
|
|
214
|
+
#
|
|
215
|
+
# camelCase props are also normalized via ActiveSupport::Inflector
|
|
216
|
+
# since component props are Ruby identifiers, not HTML attributes:
|
|
217
|
+
# cardTitle → card_title (reasonable to accept in component context)
|
|
218
|
+
def normalize_component_prop_key(name)
|
|
219
|
+
# Kebab-to-underscore first, then underscore for camelCase
|
|
220
|
+
ActiveSupport::Inflector.underscore(name.tr("-", "_"))
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|