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
@@ -0,0 +1,15 @@
1
+ module Grsx
2
+ module Nodes
3
+ class Text < 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,9 @@
1
+ module Grsx
2
+ module Nodes
3
+ module Util
4
+ def self.escape_string(str)
5
+ str.gsub('"', '\\"').gsub("'", "\\\\'")
6
+ end
7
+ end
8
+ end
9
+ end
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
@@ -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