rux 1.1.2 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74fe5ea5efe72399854ca97874db7ae0f9aa59abcc9201fc31725991881148b5
4
- data.tar.gz: 5a07947618af42f689f84784ed6a4ce095ef4693cd6e6187008cd4a5cf774b4c
3
+ metadata.gz: e2874b5336b991b08b274df04219d2c7b97247580f0587dfc3b6860e913d8af8
4
+ data.tar.gz: ba582dd96d6c58da357295e60267f4db9a6cdf47ddc99aa2740d590a1cec530d
5
5
  SHA512:
6
- metadata.gz: fe10394671a5d56275289a84013f85ae13bf84c6690febf2906ed63d54005c13aee9417db3285855a75e3fc85484b51b84daa596223cd16844ecd4fbe008d728
7
- data.tar.gz: 368880ab3405473e61114d846ec3aa54ced6490a070abd358857fcba21b373b8dcfad5eeb8a4b1100b6429a97c24e642b2c4e60bb33384baa3b9da2194d251ab
6
+ metadata.gz: c6c663d6d237fb6fc044dc2ebfa9c706d54eb5cdd3bc7a50011d82578ac7aab36a8f6c1ca466754f44bfed73ce708fc57bbd0ea3951681febd79f2730e7fd1d7
7
+ data.tar.gz: fb52a3a10cb3db47817acc7d760210758bc733b3b74b4ebfc9d241039d8d38ce00d4622957fd26effa7593acefe4d9387ebce9bcc6bab7dd20d83e9523b62322
data/CHANGELOG.md CHANGED
@@ -1,3 +1,27 @@
1
+ # 1.3.0
2
+ * Automatically add generated files to an ignore file, eg. .gitignore.
3
+ - Pass the --ignore-path=PATH flag to ruxc to indicate the file to update.
4
+ * Add the ruxlex executable that prints parser tokens for debugging purposes.
5
+ * Preserve Ruby comments in generated files.
6
+ * Fix the `as:` argument, which was being improperly generated in earlier versions.
7
+ * General parser improvements.
8
+ - Allows fragments to be nested within other tags.
9
+ - Allows tags after ruby code in branch structures like `if..else`.
10
+ * Allows HTML attributes to start with `@`.
11
+
12
+ # 1.2.0
13
+ * Improve output safety.
14
+ - HTML tags are now automatically escaped when they come from Ruby code.
15
+ * Add fragment support.
16
+ - Analogous to JSX fragments, eg. `<>foo</>`.
17
+ * Add keyword argument support in HTML attributes.
18
+ - Eg. `<div {**kwargs} bar="baz">boo</div>`.
19
+ * Add ViewComponent slot support.
20
+ - Works via pseudo components that begin with `With`, eg. `<MySlotComponent><WithItem>Item</WithItem></MySlotComponent>`.
21
+ * Allow printing `ruxc` results to STDOUT.
22
+ * Support for unquoted attributes.
23
+ * Drop explicit support for Ruby versions < 3.
24
+
1
25
  # 1.1.2
2
26
  * Don't slugify HTML attributes in the tag builder either.
3
27
 
data/Gemfile CHANGED
@@ -3,7 +3,14 @@ source 'https://rubygems.org'
3
3
  gemspec
4
4
 
5
5
  group :development, :test do
6
- gem 'pry-byebug'
6
+ gem 'debug'
7
7
  gem 'rake'
8
8
  gem 'rspec'
9
9
  end
10
+
11
+ group :development do
12
+ gem 'appraisal'
13
+ gem 'appraisal-run'
14
+ end
15
+
16
+ gem 'csv'
data/README.md CHANGED
@@ -148,6 +148,94 @@ end
148
148
 
149
149
  Notice we were able to embed Ruby within rux within Ruby within rux. Within Ruby. The rux parser supports unlimited levels of nesting, although you'll probably not want to go _too_ crazy.
150
150
 
151
+ ## Slots
152
+
153
+ Rux fully supports the view_component gem's [slots feature](https://viewcomponent.org/guide/slots.html), which allows a component to expose specific points in the rendered output where the caller can provide their own content. Let's look at a table component that exposes rows and columns via slots:
154
+
155
+ ```ruby
156
+ class TableComponent < ViewComponent::Base
157
+ renders_many :rows, RowComponent
158
+
159
+ def call
160
+ <table>
161
+ {rows.each do |row|
162
+ <>{row}</>
163
+ end}
164
+ </table>
165
+ end
166
+ end
167
+
168
+ class RowComponent < ViewComponent::Base
169
+ renders_many :columns, ColumnComponent
170
+
171
+ def call
172
+ <tr>
173
+ {columns.each do |column|
174
+ <>{column}</>
175
+ end}
176
+ </tr>
177
+ end
178
+ end
179
+
180
+ class ColumnComponent < ViewComponent::Base
181
+ def call
182
+ <td>{content}</td>
183
+ end
184
+ end
185
+ ```
186
+
187
+ Notice the use of rux fragments (analogous to JSX fragments) via the `<></>` syntax. This allows emitting a slot by dropping back to ruby via rux.
188
+
189
+ The `TableComponent` might be rendered in an ERB template like so:
190
+
191
+ ```erb
192
+ <%= render(TableComponent.new) do |table| %>
193
+ <% table.with_row do |row| %>
194
+ <% row.with_column { "Row 1, Col 1" } %>
195
+ <% row.with_column { "Row 1, Col 2" } %>
196
+ <% end %>
197
+ <% table.with_row do |row| %>
198
+ <% row.with_column { "Row 2, Col 1" } %>
199
+ <% row.with_column { "Row 2, Col 2" } %>
200
+ <% end %>
201
+ <% end %>
202
+ ```
203
+
204
+ Notice the slots are "filled in" using the `#with_row` and `#with_column` methods. In rux, these methods become components:
205
+
206
+ ```ruby
207
+ <TableComponent>
208
+ <WithRow>
209
+ <WithColumn>Row 1, Col 1</WithColumn>
210
+ <WithColumn>Row 1, Col 2</WithColumn>
211
+ </WithRow>
212
+ <WithRow>
213
+ <WithColumn>Row 2, Col 1</WithColumn>
214
+ <WithColumn>Row 2, Col 2</WithColumn>
215
+ </WithRow>
216
+ </TableComponent>
217
+ ```
218
+
219
+ ## The `as:` argument
220
+
221
+ In ViewComponent, component instances are yielded to the block on `#render`, eg:
222
+
223
+ ```erb
224
+ <%= render(MyComponent.new) do |component| %>
225
+ <%# 'component' is the instance of MyComponent passed to #render above %>
226
+ <% end %>
227
+ ```
228
+
229
+ Most of the time in rux, a reference to the component instance isn't necessary (see the section on slots above). Occasionally however it can be useful to, for example, call methods on the component instance to query its state, etc. Use the `as:` argument to assign the component instance to a local variable that's available inside the tag body:
230
+
231
+ ```ruby
232
+ <TableComponent something={value} as={table}>
233
+ {if table.something
234
+ # your code here
235
+ end}
236
+ </TableComponent>
237
+ ```
238
+
151
239
  ## Keyword Arguments Only
152
240
 
153
241
  Any view component that will be rendered by rux must _only_ accept keyword arguments in its constructor. For example:
data/bin/ruxc CHANGED
@@ -5,6 +5,7 @@ $:.push(File.expand_path('./lib'))
5
5
  require 'pathname'
6
6
  require 'optparse'
7
7
  require 'rux'
8
+ require 'onload'
8
9
 
9
10
  class RuxCLI
10
11
  def self.parse(argv)
@@ -14,8 +15,9 @@ class RuxCLI
14
15
  end
15
16
 
16
17
  options = {
17
- recursive: false,
18
- pretty: true
18
+ pretty: true,
19
+ stdout: false,
20
+ ignore_path: nil,
19
21
  }
20
22
 
21
23
  if argv.first != '-h' && argv.first != '--help'
@@ -33,6 +35,22 @@ class RuxCLI
33
35
  end
34
36
  end
35
37
 
38
+ oneline(<<~DESC).tap do |desc|
39
+ Print results to STDOUT instead of writing files to disk.
40
+ DESC
41
+ opts.on('-o', '--stdout', desc) do |stdout|
42
+ options[:stdout] = stdout
43
+ end
44
+ end
45
+
46
+ oneline(<<~DESC).tap do |desc|
47
+ Update the given git ignore file with transpiled file paths.
48
+ DESC
49
+ opts.on('-i', '--ignore-path=PATH', desc) do |ignore_path|
50
+ options[:ignore_path] = ignore_path
51
+ end
52
+ end
53
+
36
54
  opts.on('-h', '--help', 'Prints this help info') do
37
55
  puts opts
38
56
  exit
@@ -79,8 +97,21 @@ class RuxCLI
79
97
  @options[:pretty]
80
98
  end
81
99
 
100
+ def write_to_stdout?
101
+ @options[:stdout]
102
+ end
103
+
104
+ def ignore_file
105
+ return nil unless ignore_path
106
+ @ignore_file ||= Onload::IgnoreFile.load(ignore_path)
107
+ end
108
+
82
109
  private
83
110
 
111
+ def ignore_path
112
+ @options[:ignore_path]
113
+ end
114
+
84
115
  def directory?
85
116
  File.directory?(in_path)
86
117
  end
@@ -89,8 +120,19 @@ end
89
120
  cli = RuxCLI.parse(ARGV)
90
121
  cli.validate
91
122
 
92
- cli.each_file do |in_file, out_file, rbi_file|
123
+ at_exit do
124
+ cli.ignore_file&.persist!
125
+ end
126
+
127
+ cli.each_file do |in_file, out_file|
93
128
  rux_file = Rux::File.new(in_file)
94
- rux_file.write(out_file, pretty: cli.pretty?)
95
- puts "Wrote #{out_file}"
129
+
130
+ if cli.write_to_stdout?
131
+ puts rux_file.to_ruby(pretty: cli.pretty?)
132
+ else
133
+ rux_file.write(out_file, pretty: cli.pretty?)
134
+ puts "Wrote #{out_file}"
135
+
136
+ cli.ignore_file&.add(out_file)
137
+ end
96
138
  end
data/bin/ruxlex ADDED
@@ -0,0 +1,13 @@
1
+ #! /usr/bin/env ruby
2
+
3
+ $:.push(File.expand_path('./lib'))
4
+
5
+ require 'rux'
6
+
7
+ code = STDIN.read
8
+ result = Rux::Lexer.lex(code)
9
+
10
+ result.each do |(type, (str, loc))|
11
+ loc_str = loc.to_range.to_s.rjust(12, " ")
12
+ puts "#{loc_str} #{type} #{str.inspect}"
13
+ end
@@ -0,0 +1,22 @@
1
+ module Rux
2
+ module AST
3
+ class AttrNode
4
+ attr_reader :name, :value, :name_pos
5
+ attr_accessor :tag_node
6
+
7
+ def initialize(name, value, name_pos)
8
+ @name = name
9
+ @value = value
10
+ @name_pos = name_pos
11
+ end
12
+
13
+ def accept(visitor)
14
+ visitor.visit_attr(self)
15
+ end
16
+
17
+ def ruby_code?
18
+ false
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,30 @@
1
+ module Rux
2
+ module AST
3
+ class AttrsNode
4
+ include Enumerable
5
+
6
+ attr_reader :attrs, :pos
7
+
8
+ def initialize(attrs, pos)
9
+ @attrs = attrs
10
+ @pos = pos
11
+ end
12
+
13
+ def accept(visitor)
14
+ visitor.visit_attrs(self)
15
+ end
16
+
17
+ def each(&block)
18
+ attrs.each(&block)
19
+ end
20
+
21
+ def empty?
22
+ attrs.empty?
23
+ end
24
+
25
+ def get(name)
26
+ find { |attr| attr.name == name }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,15 @@
1
+ module Rux
2
+ module AST
3
+ class FragmentNode
4
+ attr_reader :children
5
+
6
+ def initialize
7
+ @children = []
8
+ end
9
+
10
+ def accept(visitor)
11
+ visitor.visit_fragment(self)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,19 @@
1
+ module Rux
2
+ module AST
3
+ class RootNode
4
+ attr_reader :list
5
+
6
+ def initialize(list)
7
+ @list = list
8
+ end
9
+
10
+ def accept(visitor)
11
+ visitor.visit_root(self)
12
+ end
13
+
14
+ def children
15
+ @children ||= [list]
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,28 @@
1
+ module Rux
2
+ module AST
3
+ class RubyAttrNode
4
+ attr_reader :ruby_node
5
+ attr_accessor :tag_node
6
+
7
+ def initialize(ruby_node)
8
+ @ruby_node = ruby_node
9
+ end
10
+
11
+ def code
12
+ ruby_node.code
13
+ end
14
+
15
+ def accept(visitor)
16
+ visitor.visit_attr(self)
17
+ end
18
+
19
+ def ruby_code?
20
+ true
21
+ end
22
+
23
+ def name
24
+ nil
25
+ end
26
+ end
27
+ end
28
+ end
@@ -1,10 +1,12 @@
1
1
  module Rux
2
2
  module AST
3
3
  class StringNode
4
- attr_reader :str
4
+ attr_reader :str, :quote_type, :pos
5
5
 
6
- def initialize(str)
6
+ def initialize(str, quote_type, pos)
7
7
  @str = str
8
+ @quote_type = quote_type
9
+ @pos = pos
8
10
  end
9
11
 
10
12
  def accept(visitor)
@@ -3,9 +3,10 @@ module Rux
3
3
  class TagNode
4
4
  attr_reader :name, :attrs, :children
5
5
 
6
- def initialize(name, attrs)
6
+ def initialize(name, attrs, pos)
7
7
  @name = name
8
8
  @attrs = attrs
9
+ @pos = pos
9
10
  @children = []
10
11
  end
11
12
 
@@ -16,6 +17,14 @@ module Rux
16
17
  def component?
17
18
  name.start_with?(/[A-Z]/)
18
19
  end
20
+
21
+ def slot_component?
22
+ name.start_with?("With")
23
+ end
24
+
25
+ def slot_method
26
+ @slot_method ||= name.gsub(/(?<!^)([A-Z])/) { |x| "_#{x}" }.downcase
27
+ end
19
28
  end
20
29
  end
21
30
  end
@@ -5,8 +5,9 @@ module Rux
5
5
  class TextNode
6
6
  attr_reader :text
7
7
 
8
- def initialize(text)
8
+ def initialize(text, pos)
9
9
  @text = text
10
+ @pos = pos
10
11
  end
11
12
 
12
13
  def accept(visitor)
data/lib/rux/ast.rb CHANGED
@@ -1,9 +1,14 @@
1
1
  module Rux
2
2
  module AST
3
- autoload :ListNode, 'rux/ast/list_node'
4
- autoload :RubyNode, 'rux/ast/ruby_node'
5
- autoload :StringNode, 'rux/ast/string_node'
6
- autoload :TagNode, 'rux/ast/tag_node'
7
- autoload :TextNode, 'rux/ast/text_node'
3
+ autoload :AttrNode, 'rux/ast/attr_node'
4
+ autoload :AttrsNode, 'rux/ast/attrs_node'
5
+ autoload :FragmentNode, 'rux/ast/fragment_node'
6
+ autoload :ListNode, 'rux/ast/list_node'
7
+ autoload :RootNode, 'rux/ast/root_node'
8
+ autoload :RubyAttrNode, 'rux/ast/ruby_attr_node'
9
+ autoload :RubyNode, 'rux/ast/ruby_node'
10
+ autoload :StringNode, 'rux/ast/string_node'
11
+ autoload :TagNode, 'rux/ast/tag_node'
12
+ autoload :TextNode, 'rux/ast/text_node'
8
13
  end
9
14
  end
data/lib/rux/buffer.rb CHANGED
@@ -1,15 +1,33 @@
1
1
  module Rux
2
+ class SafeString < String
3
+ def html_safe?
4
+ true
5
+ end
6
+ end
7
+
2
8
  class Buffer
3
9
  def initialize(init_str = '')
4
10
  @string = init_str.dup
5
11
  end
6
12
 
7
- def <<(*obj)
8
- @string << obj.join
13
+ def append(obj)
14
+ Array(obj).each do |o|
15
+ @string << if o.respond_to?(:html_safe?) && o.html_safe?
16
+ o.to_s
17
+ else
18
+ CGI.escapeHTML(o.to_s)
19
+ end
20
+ end
21
+ end
22
+
23
+ def safe_append(obj)
24
+ Array(obj).each { |o| @string << o.to_s }
9
25
  end
10
26
 
11
27
  def to_s
12
- @string
28
+ SafeString.new(@string)
13
29
  end
30
+
31
+ alias html_safe to_s
14
32
  end
15
33
  end
@@ -1,19 +1,28 @@
1
1
  module Rux
2
2
  class DefaultTagBuilder
3
- def call(tag_name, attributes = {})
4
- attr_str = attributes.empty? ? '' : " #{serialize_attrs(attributes)}"
5
- "<#{tag_name}#{attr_str}>" <<
6
- (block_given? ? Array(yield) : []).join <<
7
- "</#{tag_name}>"
3
+ def call(tag_name, attributes = {}, &block)
4
+ SafeString.new(build(tag_name, attributes, &block))
8
5
  end
9
6
 
10
7
  private
11
8
 
9
+ def build(tag_name, attributes = {})
10
+ attr_str = attributes.empty? ? '' : " #{serialize_attrs(attributes)}"
11
+
12
+ "<#{tag_name}#{attr_str}>".tap do |result|
13
+ if block_given?
14
+ Array(yield).each { |body| result << body }
15
+ end
16
+
17
+ result << "</#{tag_name}>"
18
+ end
19
+ end
20
+
12
21
  def serialize_attrs(attributes)
13
22
  ''.tap do |result|
14
23
  attributes.each_pair.with_index do |(k, v), idx|
15
24
  result << ' ' unless idx == 0
16
- result << "#{k}=\"#{CGI.escape_html(v.to_s)}\""
25
+ result << "#{k}=\"#{v.to_s.gsub('"', "&quot;")}\""
17
26
  end
18
27
  end
19
28
  end
@@ -2,8 +2,18 @@ require 'cgi'
2
2
 
3
3
  module Rux
4
4
  class DefaultVisitor < Visitor
5
+ def initialize
6
+ @render_stack = []
7
+ end
8
+
9
+ def visit_root(node)
10
+ visit_list(node.list)
11
+ end
12
+
5
13
  def visit_list(node)
6
- node.children.map { |child| visit(child) }.join
14
+ ''.tap do |result|
15
+ node.children.each { |child| result << visit(child) }
16
+ end
7
17
  end
8
18
 
9
19
  def visit_ruby(node)
@@ -11,57 +21,124 @@ module Rux
11
21
  end
12
22
 
13
23
  def visit_string(node)
14
- node.str
24
+ case node.quote_type
25
+ when :single
26
+ "'#{node.str}'"
27
+ else
28
+ "\"#{node.str}\""
29
+ end
15
30
  end
16
31
 
17
32
  def visit_tag(node)
18
33
  ''.tap do |result|
19
- block_arg = if (as = node.attrs['as'])
20
- visit(as)
34
+ block_arg = if (as = node.attrs.get('as'))
35
+ visit(as.value)
21
36
  end
22
37
 
23
- at = node.attrs.each_with_object([]) do |(k, v), ret|
24
- next if k == 'as'
25
- ret << Utils.attr_to_hash_elem(k, visit(v), slugify: node.component?)
26
- end
38
+ block_arg ||= "rux_block_arg#{@render_stack.size}"
27
39
 
28
- if node.component?
40
+ if node.slot_component?
41
+ result << "(#{parent_render[:block_arg]}.#{node.slot_method}"
42
+
43
+ unless node.attrs.empty?
44
+ result << "(#{visit(node.attrs)})"
45
+ end
46
+ elsif node.component?
29
47
  result << "render(#{node.name}.new"
30
48
 
31
49
  unless node.attrs.empty?
32
- result << "(#{at.join(', ')})"
50
+ result << "(#{visit(node.attrs)})"
33
51
  end
52
+
53
+ result << ')'
34
54
  else
35
55
  result << "Rux.tag('#{node.name}'"
36
56
 
37
57
  unless node.attrs.empty?
38
- result << ", { #{at.join(', ')} }"
58
+ result << ", { #{visit(node.attrs)} }"
39
59
  end
60
+
61
+ result << ')'
40
62
  end
41
63
 
42
- result << ')'
64
+ @render_stack.push({
65
+ component_name: node.name,
66
+ block_arg: block_arg
67
+ })
43
68
 
44
- if node.children.size > 1
69
+ if node.children.size > 0
45
70
  result << " { "
46
- result << "|#{block_arg}| " if block_arg
71
+ result << "|#{block_arg}| " if block_arg && node.component?
47
72
  result << "Rux.create_buffer.tap { |_rux_buf_| "
48
73
 
49
- node.children.each do |child|
50
- result << "_rux_buf_ << #{visit(child).strip};"
51
- end
52
-
74
+ result << visit_tag_children(node).join
53
75
  result << " }.to_s }"
54
- elsif node.children.size == 1
55
- result << ' { '
56
- result << "|#{block_arg}| " if block_arg
57
- result << visit(node.children.first).strip
58
- result << ' }'
59
76
  end
77
+
78
+ # don't pass instances of ViewComponent::Slot to _rux_buf_#<< by wrapping
79
+ # the slot setter return value in (retval; nil)
80
+ if node.slot_component?
81
+ result << "; nil)"
82
+ end
83
+
84
+ @render_stack.pop
85
+ end
86
+ end
87
+
88
+ def visit_tag_children(node)
89
+ node.children.map do |child|
90
+ append_statement_for(child)
91
+ end
92
+ end
93
+
94
+ def append_statement_for(node)
95
+ if node.is_a?(AST::TextNode)
96
+ "_rux_buf_.safe_append(#{visit(node).strip.chomp(';')});"
97
+ else
98
+ "_rux_buf_.append(#{visit(node).strip.chomp(';')});"
99
+ end
100
+ end
101
+
102
+ def visit_attrs(node)
103
+ visited_attrs = node.attrs.each_with_object([]) do |attr, memo|
104
+ memo << visit(attr) unless attr.name == "as"
105
+ end
106
+
107
+ visited_attrs.join(", ")
108
+ end
109
+
110
+ def visit_attr(node)
111
+ if node.ruby_code?
112
+ node.code
113
+ else
114
+ Utils.attr_to_hash_elem(
115
+ node.name,
116
+ visit(node.value),
117
+ slugify: node.tag_node.component?
118
+ )
119
+ end
120
+ end
121
+
122
+ def visit_fragment(node)
123
+ ''.tap do |result|
124
+ result << "Rux.create_buffer.tap { |_rux_buf_| "
125
+
126
+ node.children.each do |child|
127
+ result << append_statement_for(child)
128
+ end
129
+
130
+ result << " }.to_s;"
60
131
  end
61
132
  end
62
133
 
63
134
  def visit_text(node)
64
135
  "\"#{CGI.escape_html(node.text)}\""
65
136
  end
137
+
138
+ private
139
+
140
+ def parent_render
141
+ @render_stack.last
142
+ end
66
143
  end
67
144
  end