erb2rux 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4a3f63bab9bef707b611464f3d952d7f505e22d0593976105cfc803eff905685
4
+ data.tar.gz: c45fa2a5e37eedac6c0d095fe3441e840e8e2b3c640076cbd74440226e7d5db5
5
+ SHA512:
6
+ metadata.gz: 06e32ead01821495acab5335a36a407ab9bc36e32a331b9775e35e92c49b0dd8b9f3ea3438f9ad24219e7a2fb4565f9e93629794414f2ad02a950732a04c7de5
7
+ data.tar.gz: 87372c28d66eb2e8c971d18537999359b14731293e8d9a3dfec57a95ce0d050eaa8acfbdd7bd07cb51e3b60eddb13a664d947ad77e3cf0f28c11f1b04328cc39
data/Gemfile ADDED
@@ -0,0 +1,9 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
4
+
5
+ group :development, :test do
6
+ gem 'pry-byebug'
7
+ gem 'rake'
8
+ gem 'rspec'
9
+ end
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2021 Cameron Dutro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,45 @@
1
+ ## erb2rux
2
+
3
+ ![Unit Tests](https://github.com/camertron/erb2rux/actions/workflows/unit_tests.yml/badge.svg?branch=main)
4
+
5
+ erb2rux is an ERB to [Rux](https://github.com/camertron/rux) converter. It's used to translate Rails view files, usually written in ERB (embedded Ruby) syntax, into Rux syntax. Rux allows you to write HTML in your Ruby code, much like JSX allows you to write HTML in your JavaScript. It's great for rendering [view components](https://viewcomponent.org/).
6
+
7
+ ## Installation
8
+
9
+ Simply run `gem install erb2rux`.
10
+
11
+ ## Usage
12
+
13
+ The project ships with a single executable called `erb2rux`. It takes any number of files as arguments, or a single "-" character to read from [standard input](https://en.wikipedia.org/wiki/Standard_streams). In the case of standard input, `erb2rux` will print the resulting Rux code to standard output (i.e. your terminal screen). Otherwise, the list of files will be transpiled and written to the same location as the original file, with either the default extension (.html.ruxt) or one you specify.
14
+
15
+ Here's an example showing how to transpile a single file:
16
+
17
+ ```bash
18
+ erb2rux app/views/products/index.html.erb
19
+ ```
20
+
21
+ This will create app/views/products/index.html.ruxt containing Rux code equivalent to the given ERB file.
22
+
23
+ To use a different extension, pass the -x option:
24
+
25
+ ```bash
26
+ erb2rux -x .html.rux app/views/products/index.html.erb
27
+ ```
28
+
29
+ Finally, here's the equivalent command using standard in/out:
30
+
31
+ ```bash
32
+ cat app/views/products/index.html.erb | erb2rux -
33
+ ```
34
+
35
+ ## Running Tests
36
+
37
+ `bundle exec rspec` should do the trick.
38
+
39
+ ## License
40
+
41
+ Licensed under the MIT license. See LICENSE for details.
42
+
43
+ ## Authors
44
+
45
+ * Cameron C. Dutro: http://github.com/camertron
data/Rakefile ADDED
@@ -0,0 +1,14 @@
1
+ require 'bundler'
2
+ require 'rspec/core/rake_task'
3
+ require 'rubygems/package_task'
4
+
5
+ require 'erb2rux'
6
+
7
+ Bundler::GemHelper.install_tasks
8
+
9
+ task default: :spec
10
+
11
+ desc 'Run specs'
12
+ RSpec::Core::RakeTask.new do |t|
13
+ t.pattern = './spec/**/*_spec.rb'
14
+ end
data/bin/erb2rux ADDED
@@ -0,0 +1,77 @@
1
+ #! /usr/bin/env ruby
2
+
3
+ $:.push(File.expand_path('./lib'))
4
+
5
+ require 'optparse'
6
+ require 'erb2rux'
7
+
8
+ class Erb2RuxCLI
9
+ def self.parse(argv)
10
+ if argv.empty?
11
+ puts 'Please pass a list of files to transpile, or - to read from STDIN'
12
+ exit 1
13
+ end
14
+
15
+ options = {
16
+ extension: '.html.ruxt'
17
+ }
18
+
19
+ parser = OptionParser.new do |opts|
20
+ opts.banner = "Usage: erb2rux [options] paths"
21
+
22
+ oneline(<<~DESC).tap do |desc|
23
+ The file extension to use for output files. Ignored if reading from STDIN
24
+ (default: #{options[:extension]}).
25
+ DESC
26
+ opts.on('-xEXT', '--extension=EXT', desc) do |ext|
27
+ options[:extension] = ext
28
+ end
29
+ end
30
+
31
+ opts.on('-h', '--help', 'Prints this help info') do
32
+ puts opts
33
+ exit
34
+ end
35
+ end
36
+
37
+ parser.parse!(argv)
38
+ new({ **options, files: argv })
39
+ end
40
+
41
+ def self.oneline(str)
42
+ str.split("\n").join(' ')
43
+ end
44
+
45
+ def initialize(options)
46
+ @options = options
47
+ end
48
+
49
+ def stdin?
50
+ @options[:files].first == '-'
51
+ end
52
+
53
+ def each_file(&block)
54
+ @options[:files].each do |in_file|
55
+ ext_idx = in_file.index('.')
56
+ out_file = "#{in_file[0...ext_idx]}#{extension}"
57
+ yield in_file, out_file
58
+ end
59
+ end
60
+
61
+ def extension
62
+ @options[:extension]
63
+ end
64
+ end
65
+
66
+ cli = Erb2RuxCLI.parse(ARGV)
67
+
68
+ if cli.stdin?
69
+ puts Erb2Rux::Transformer.transform(STDIN.read)
70
+ exit 0
71
+ end
72
+
73
+ cli.each_file do |in_file, out_file|
74
+ result = Erb2Rux::Transformer.transform(File.read(in_file))
75
+ File.write(out_file, result)
76
+ puts "Wrote #{out_file}"
77
+ end
data/erb2rux.gemspec ADDED
@@ -0,0 +1,22 @@
1
+ $:.unshift File.join(File.dirname(__FILE__), 'lib')
2
+ require 'erb2rux/version'
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = 'erb2rux'
6
+ s.version = ::Erb2Rux::VERSION
7
+ s.authors = ['Cameron Dutro']
8
+ s.email = ['camertron@gmail.com']
9
+ s.homepage = 'http://github.com/camertron/erb2rux'
10
+ s.description = s.summary = 'Automatically convert .html.erb files into .rux files.'
11
+ s.platform = Gem::Platform::RUBY
12
+
13
+ s.add_dependency 'actionview', '~> 6.1'
14
+ s.add_dependency 'parser', '~> 3.0'
15
+ s.add_dependency 'unparser', '~> 0.6'
16
+
17
+ s.require_path = 'lib'
18
+
19
+ s.executables << 'erb2rux'
20
+
21
+ s.files = Dir['{lib,spec}/**/*', 'Gemfile', 'LICENSE', 'CHANGELOG.md', 'README.md', 'Rakefile', 'erb2rux.gemspec']
22
+ end
@@ -0,0 +1,49 @@
1
+ module Erb2Rux
2
+ class ComponentRender
3
+ attr_reader :component_name, :component_kwargs
4
+ attr_reader :send_range, :block_end_range, :block_body_range, :block_arg
5
+
6
+ def initialize(component_name:, component_kwargs:, send_range:, block_end_range:, block_body_range:, block_arg:)
7
+ @component_name = component_name
8
+ @component_kwargs = component_kwargs
9
+ @send_range = send_range
10
+ @block_end_range = block_end_range
11
+ @block_body_range = block_body_range
12
+ @block_arg = block_arg
13
+ end
14
+
15
+ def close_tag
16
+ @close_tag ||= "</#{component_name}>"
17
+ end
18
+
19
+ def open_tag
20
+ @open_tag ||= ''.tap do |result|
21
+ result << "<#{component_name}"
22
+ result << ' ' unless kwargs.empty?
23
+ result << kwargs
24
+ result << '>'
25
+ end
26
+ end
27
+
28
+ def self_closing_tag
29
+ @self_closing_tag ||= ''.tap do |result|
30
+ result << "<#{component_name}"
31
+ result << ' ' unless kwargs.empty?
32
+ result << kwargs
33
+ result << ' />'
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def kwargs
40
+ @kwargs = begin
41
+ kwargs = component_kwargs.map do |key, value|
42
+ "#{key}={#{value}}"
43
+ end
44
+ kwargs << "as={\"#{block_arg}\"}" if block_arg
45
+ kwargs.join(' ')
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,405 @@
1
+ require 'action_view'
2
+ require 'parser'
3
+ require 'unparser'
4
+
5
+ module Erb2Rux
6
+ NodeMeta = Struct.new(:node, :stype, :replacement, :in_code)
7
+
8
+ class Transformer
9
+ class << self
10
+ def transform(source)
11
+ # Remove any extra spaces between the ERB tag and the content, eg.
12
+ # "<%= foo %>" becomes "<%=foo%>". ActionView's Erubi parser
13
+ # treats this extra whitespace as part of the output, which results
14
+ # in funky indentation issues.
15
+ source.gsub!(/(<%=?) */, '\1')
16
+ source.gsub!(/ *(%>)/, '\1')
17
+
18
+ # This is how Rails translates ERB to Ruby code, so it's probably the
19
+ # way we should do it too. It takes blocks into account, which, TIL, is
20
+ # something regular ERB doesn't do. The resulting ruby code works by
21
+ # appending to an @output_buffer instance variable. Different methods
22
+ # are used for chunks of code vs HTML strings, which allows the
23
+ # transformer to distinguish between them. See #send_type_for for
24
+ # details.
25
+ ruby_code = ActionView::Template::Handlers::ERB::Erubi.new(source).src
26
+
27
+ # ActionView adds this final line at the end of all compiled templates,
28
+ # so we need to remmove it.
29
+ ruby_code = ruby_code.chomp('@output_buffer.to_s')
30
+ ast = ::Parser::CurrentRuby.parse(ruby_code)
31
+ rewrite(ast)
32
+ end
33
+
34
+ private
35
+
36
+ # Determines whether or not the given node is itself a node (i.e. an
37
+ # instance of Parser::AST::Node) that identifies a visible chunk of
38
+ # code in the source buffer. (In addition to being Node instances,
39
+ # children can be strings, symbols, or nil, and sometimes only hold
40
+ # metadata, i.e. don't reference a visible portion of the source).
41
+ def is_node?(obj)
42
+ obj.respond_to?(:children) && obj.location.expression
43
+ end
44
+
45
+ def rewrite(node)
46
+ return unless is_node?(node)
47
+
48
+ # The "send type" refers to the way this node is being appended to the
49
+ # output buffer. <%= %> tags result in a call to append=, which has a
50
+ # send type of :code. <% %> tags result in no append calls and have a
51
+ # send type of nil. Finally, regular 'ol strings (i.e. HTML) result in
52
+ # a call to safe_append= and have a send type of :string.
53
+ case send_type_for(node)
54
+ when :code
55
+ rewrite_code(node)
56
+ when :string
57
+ rewrite_string(node)
58
+ else
59
+ # Code in <% %> tags should be left as-is. Instead of rewriting it
60
+ # we recurse and process all the children.
61
+ rewrite_children(node)
62
+ end
63
+ end
64
+
65
+ def rewrite_code(node)
66
+ # Any node that is passed to this method will be a s(:send) node that
67
+ # identifies a chunk of Ruby code that should be surrounded by Rux
68
+ # braces. The arg below is the argument to the @output_buffer.append=
69
+ # call.
70
+ _, _, arg, * = *node
71
+
72
+ # Does this node render a view component?
73
+ if (component_render = identify_component_render(arg))
74
+ _, _, block_body, = *arg
75
+
76
+ return arg.location.expression.source.dup.tap do |code|
77
+ # If the render call has a block...
78
+ if block_body
79
+ # ...replace the end statement with the closing tag, recursively
80
+ # rewrite the block's body, and replace the render call with the
81
+ # opening tag. Do these things in reverse order so all the ranges
82
+ # are valid.
83
+ code[component_render.block_end_range] = component_render.close_tag
84
+ leading_ws, body, trailing_ws = ws_split(rewrite(block_body))
85
+
86
+ code[component_render.block_body_range] = if body.empty?
87
+ "#{leading_ws}#{trailing_ws}"
88
+ else
89
+ "#{leading_ws}{#{body}}#{trailing_ws}"
90
+ end
91
+
92
+ code[component_render.send_range] = component_render.open_tag
93
+ else
94
+ # ...otherwise only replace the render call and self-close the tag.
95
+ code[component_render.send_range] = component_render.self_closing_tag
96
+ end
97
+ end
98
+ end
99
+
100
+ # ActionView wraps code in parens (eg. @output_buffer.append= ( foo ))
101
+ # which results in an extra s(:begin) node wrapped around the code node.
102
+ # Strip it off.
103
+ arg_body = arg.type == :begin ? arg.children[0] : arg
104
+ rewrite(arg_body)
105
+ end
106
+
107
+ def rewrite_string(node)
108
+ # Any node that is passed to this method will be a s(:send) node that
109
+ # identifies a chunk of HTML. The arg below is the argument to the
110
+ # @output_buffer.safe_append= call.
111
+ _, _, arg, * = *node
112
+
113
+ # Strip off quotes.
114
+ str = arg.children[0].location.expression.source[1..-2]
115
+
116
+ # Rux leaves HTML as-is, i.e. doesn't quote it.
117
+ unless tag_start?(str)
118
+ # ActionView appends every literal string it finds in the ERB source,
119
+ # which includes newlines and other incidental whitespace that we
120
+ # programmers frequently use to indent our code and otherwise make it
121
+ # look readable to other humans. This extra whitespace isn't part of
122
+ # the string itself, but because there's nothing in ERB to indicate
123
+ # where whitespace should stop and the string should start,
124
+ # ActionView just sort of smushes it all together. The whitespace is
125
+ # important for the aforementioned formatting reasons, so it
126
+ # shouldn't just be thrown away. Instead, the following lines extract
127
+ # the important string part along with the leading and trailing
128
+ # whitespace, then quote the string and stick all the parts back
129
+ # together. Seems to work ok.
130
+ leading_ws, str, trailing_ws = ws_split(str)
131
+ str = "#{leading_ws}#{rb_quote(str)}#{trailing_ws}"
132
+ end
133
+
134
+ str
135
+ end
136
+
137
+ def tag_start?(str)
138
+ str.strip.start_with?('<')
139
+ end
140
+
141
+ def ws_split(str)
142
+ leading_ws = str.match(/\A(\s*)/)
143
+ # Pass the second arg here to avoid considering the same whitespace
144
+ # as both leading _and_ trailing, as in the case where the string is
145
+ # entirely whitespace, etc.
146
+ trailing_ws = str.match(/(\s*)\z/, leading_ws.end(0))
147
+ middle = str[leading_ws.end(0)...(trailing_ws.begin(0))]
148
+ [leading_ws.captures[0], middle, trailing_ws.captures[0]]
149
+ end
150
+
151
+ # Because we have to perform replacements in reverse order to avoid
152
+ # invalidating the ranges that come later in the source, it's impossible
153
+ # to know whether a particular node is already wrapped in Rux curly
154
+ # braces or not. In other words, whether or not the current node exists
155
+ # inside a code block is entirely determined by the nodes that come
156
+ # before it, which cannot be considered when iterating in reverse order.
157
+ # To mitigate this problem, we first iterate in a forward manner and
158
+ # accrue metadata for each node. The NodeMeta struct holds a reference
159
+ # to the node, meaning that the replacement algorithm can simply iterate
160
+ # backwards through a list of them.
161
+ def calc_node_meta(nodes)
162
+ # Start out assuming we're in code. This is also what the Rux parser
163
+ # does.
164
+ in_code = true
165
+
166
+ nodes.each_with_object([]) do |child_node, memo|
167
+ next unless is_node?(child_node)
168
+
169
+ stype = send_type_for(child_node)
170
+ replacement = rewrite(child_node)
171
+
172
+ memo << NodeMeta.new(child_node, stype, replacement, in_code)
173
+
174
+ case stype
175
+ when :string
176
+ if tag_start?(replacement)
177
+ # If we're inside an HTML tag, that must mean we're not in a
178
+ # code block anymore.
179
+ in_code = false
180
+ end
181
+ when :code
182
+ in_code = true
183
+ end
184
+ end
185
+ end
186
+
187
+ def rewrite_children(node)
188
+ node_loc = node.location.expression
189
+ child_meta = calc_node_meta(node.children)
190
+
191
+ node_loc.source.dup.tap do |result|
192
+ # The replacement algorithm needs to be able to consider the previous
193
+ # node's metadata, so we use a sliding window of 2.
194
+ reverse_each_cons(2, child_meta) do |prev, cur|
195
+ next unless cur
196
+
197
+ child_loc = cur.node.location.expression
198
+
199
+ # A code node inside HTML who's replacement isn't HTML. Needs to be
200
+ # wrapped in Rux curlies.
201
+ if (cur.stype == :code || cur.stype == nil) && !cur.in_code && !tag_start?(cur.replacement)
202
+ cur.replacement = "{#{cur.replacement}}"
203
+ end
204
+
205
+ # ERB nodes that occur right next to each other should be concatenated.
206
+ # Eg: foo <%= bar %> should result in "foo" + bar.
207
+ # This check makes sure that:
208
+ # 1. Both the previous and current nodes are ERB code (i.e. are
209
+ # :code or :string).
210
+ # 2. Both the previous and current nodes are in a code block. If
211
+ # they're not, it doesn't make much sense to concatenate them
212
+ # using Ruby's `+' operator.
213
+ # 3. Both the previous and current nodes aren't 100% whitespace,
214
+ # which would indicate they're for formatting purposes and don't
215
+ # contain actual code.
216
+ should_concat =
217
+ prev &&
218
+ cur.stype &&
219
+ prev.stype &&
220
+ cur.in_code &&
221
+ prev.in_code &&
222
+ !cur.replacement.strip.empty? &&
223
+ !prev.replacement.strip.empty?
224
+
225
+ if should_concat
226
+ cur.replacement = " + #{cur.replacement}"
227
+ end
228
+
229
+ begin_pos = child_loc.begin_pos - node_loc.begin_pos
230
+ end_pos = child_loc.end_pos - node_loc.begin_pos
231
+ # Trim off those pesky trailing semicolons ActionView adds.
232
+ end_pos += 1 if result[end_pos] == ';'
233
+ result[begin_pos...end_pos] = cur.replacement
234
+ end
235
+ end
236
+ end
237
+
238
+ # Iterates backwards over the `items` enumerable and yields a sliding
239
+ # window of `size` elements.
240
+ #
241
+ # For example, reverse_each_cons(3, %w(a b c d)) yields the following:
242
+ #
243
+ # ["d", nil, nil]
244
+ # ["c", "d", nil]
245
+ # ["b", "c", "d"]
246
+ # ["a", "b", "c"]
247
+ # [nil, "a", "b"]
248
+ # [nil, nil, "a"]
249
+ def reverse_each_cons(size, items)
250
+ slots = Array.new(size)
251
+ enum = items.reverse_each
252
+ stops = nil
253
+
254
+ loop do
255
+ item = begin
256
+ stops += 1 if stops
257
+ stops ? nil : enum.next
258
+ rescue StopIteration
259
+ stops = 1
260
+ nil
261
+ end
262
+
263
+ slots.unshift(item)
264
+ slots.pop
265
+
266
+ yield slots
267
+
268
+ # It's not useful to anybody to yield an array of all nils.
269
+ break if stops == size - 1
270
+ end
271
+ end
272
+
273
+ def identify_component_render(node)
274
+ send_node, *block_args_node = *node
275
+ block_arg_nodes = block_args_node[0]&.children || []
276
+ # Doesn't make sense for a component render to have more than one
277
+ # block argument.
278
+ return if block_arg_nodes.size > 1
279
+
280
+ block_arg_node, = *block_arg_nodes
281
+ return if block_arg_node && block_arg_node.type != :arg
282
+
283
+ block_arg_name, = *block_arg_node
284
+
285
+ receiver_node, method_name, *render_args = *send_node
286
+ # There must be no receiver (i.e. no dot), the method name must be
287
+ # "render," and it must take exactly one argument.
288
+ return if receiver_node || method_name != :render
289
+ return if !render_args || render_args.size != 1
290
+
291
+ render_arg, = *render_args
292
+ # The argument passed to render must be a s(:send) node, which
293
+ # indicates the presence of .new being called on a component class.
294
+ # This seems fine for now, but might need to change if we find there
295
+ # are other common ways to pass component instances to render.
296
+ return if render_arg.type != :send
297
+
298
+ component_name_node, _, *component_args = *render_arg
299
+ # The Parser gem treats keyword args as a single arg, and since Rux
300
+ # doesn't allow positional arguments, it's best to leave non-conforming
301
+ # renders alone and bail out here.
302
+ return if component_args.size > 1
303
+
304
+ component_kwargs, = *component_args
305
+
306
+ kwargs = if component_kwargs
307
+ # Whatever this first argument is, it'd better be a hash. This is how
308
+ # the Parser gem parses keyword arguments, even though kwargs are no
309
+ # longer treated as hashes in modern Rubies.
310
+ return unless component_kwargs.type == :hash
311
+
312
+ # Build up an array of 2-element [key, value] arrays
313
+ component_kwargs.children.map do |component_kwarg|
314
+ key, value = *component_kwarg
315
+ return unless [:sym, :str].include?(key.type)
316
+
317
+ [key.children[0], Unparser.unparse(value)]
318
+ end
319
+ else
320
+ # It's perfectly ok for components not to accept any args
321
+ []
322
+ end
323
+
324
+ source = node.location.expression.source
325
+
326
+ # This is the base position all other positions should start from.
327
+ # The Parser gem's position information is all absolute, i.e.
328
+ # measured from the beginning of the original source buffer. We want to
329
+ # consider only this one node, meaning all positions must be adjusted
330
+ # so they are relative to it.
331
+ start = node.location.expression.begin_pos
332
+ send_stop = if block_arg_node
333
+ # Annoyingly, the Parser gem doesn't include the trailing block
334
+ # terminator pipe in location.end. We have to find it manually with
335
+ # this #index call instead. What a pain.
336
+ block_arg_start = block_arg_node.location.expression.begin_pos - start
337
+ source.index('|', block_arg_start) + 1
338
+ else
339
+ # If we get to this point and there is no block passed to render,
340
+ # that means we're looking at the surrounding block Erubi adds
341
+ # around Ruby code (effectively surrounding it with parens). In such
342
+ # a case, the "begin" location points to the opening left paren. If
343
+ # instead there _is_ a block passed to render, the "begin" location
344
+ # points to the "do" statement. Truly confusing, but here we are.
345
+ if node.location.begin.source == 'do'
346
+ node.location.begin.end_pos - start
347
+ else
348
+ node.location.expression.end_pos - start
349
+ end
350
+ end
351
+
352
+ block_end_range = nil
353
+ block_body_range = nil
354
+
355
+ if node.type == :block
356
+ # Use index here to find the first non-whitespace character after the
357
+ # render call, as well as the first non-whitespace character before
358
+ # the end of the "end" statement.
359
+ block_body_start = source.index(/\S/, send_stop)
360
+ block_body_end = source.rindex(/\S/, node.location.end.begin_pos - start - 1) + 1
361
+
362
+ block_end = node.location.end
363
+ block_end_range = (block_end.begin_pos - start)...(block_end.end_pos - start)
364
+ block_body_range = block_body_start...block_body_end
365
+ end
366
+
367
+ return ComponentRender.new(
368
+ component_name: Unparser.unparse(component_name_node),
369
+ component_kwargs: kwargs,
370
+ send_range: 0...send_stop,
371
+ block_end_range: block_end_range,
372
+ block_body_range: block_body_range,
373
+ block_arg: block_arg_name
374
+ )
375
+ end
376
+
377
+ # Escapes double quotes, then double quotes the result.
378
+ def rb_quote(str)
379
+ return '' if !str || str.empty?
380
+ "\"#{str.gsub("\"", "\\\"")}\""
381
+ end
382
+
383
+ def send_type_for(node)
384
+ return unless is_node?(node)
385
+
386
+ receiver_node, method_name, = *node
387
+ return unless is_node?(receiver_node)
388
+
389
+ # Does this node indicate a method called on the @output_buffer
390
+ # instance variable?
391
+ is_buffer_append = receiver_node.type == :ivar &&
392
+ receiver_node.children[0] == :@output_buffer
393
+
394
+ return unless is_buffer_append
395
+
396
+ case method_name
397
+ when :safe_append=
398
+ :string
399
+ when :append=
400
+ :code
401
+ end
402
+ end
403
+ end
404
+ end
405
+ end
@@ -0,0 +1,3 @@
1
+ module Erb2Rux
2
+ VERSION = '0.1.0'
3
+ end
data/lib/erb2rux.rb ADDED
@@ -0,0 +1,4 @@
1
+ module Erb2Rux
2
+ autoload :ComponentRender, 'erb2rux/component_render'
3
+ autoload :Transformer, 'erb2rux/transformer'
4
+ end
@@ -0,0 +1,16 @@
1
+ $:.push(__dir__)
2
+
3
+ require 'rspec'
4
+ require 'erb2rux'
5
+ require 'pry-byebug'
6
+
7
+ Dir.chdir(__dir__) do
8
+ Dir['support/*.rb'].each { |f| require f }
9
+ end
10
+
11
+ module SpecHelpers
12
+ end
13
+
14
+ RSpec.configure do |config|
15
+ config.include SpecHelpers
16
+ end
@@ -0,0 +1,257 @@
1
+ require 'spec_helper'
2
+
3
+ describe Erb2Rux::Transformer do
4
+ def transform(str)
5
+ described_class.transform(str)
6
+ end
7
+
8
+ it 'handles a literal string' do
9
+ expect(transform('foo')).to eq('"foo"')
10
+ end
11
+
12
+ it 'handles a single variable' do
13
+ expect(transform('<%= foo %>')).to eq('foo')
14
+ end
15
+
16
+ it 'concatenates strings and code' do
17
+ expect(transform('foo <%= bar %>')).to eq('"foo" + bar')
18
+ end
19
+
20
+ it 'handles a simple if statement' do
21
+ result = transform(<<~ERB).strip
22
+ <% if foo %>
23
+ bar
24
+ <% else %>
25
+ <%= baz %>
26
+ <% end %>
27
+ ERB
28
+
29
+ expect(result).to eq(<<~RUX.strip)
30
+ if foo
31
+ "bar"
32
+ else
33
+ baz
34
+ end
35
+ RUX
36
+ end
37
+
38
+ it 'wraps code in curlies' do
39
+ expect(transform('<a><%= foo %></a>')).to eq('<a>{foo}</a>')
40
+ end
41
+
42
+ it 'wraps control structures in curlies' do
43
+ result = transform(<<~ERB).strip
44
+ <a>
45
+ <% if foo %>
46
+ bar
47
+ <% else %>
48
+ <%= baz %>
49
+ <% end %>
50
+ </a>
51
+ ERB
52
+
53
+ expect(result).to eq(<<~RUX.strip)
54
+ <a>
55
+ {if foo
56
+ "bar"
57
+ else
58
+ baz
59
+ end}
60
+ </a>
61
+ RUX
62
+ end
63
+
64
+ it 'handles component renders' do
65
+ result = transform(<<~ERB).strip
66
+ <%= render(FooComponent.new) %>
67
+ ERB
68
+
69
+ expect(result).to eq('<FooComponent />')
70
+ end
71
+
72
+ it 'handles component renders with arguments' do
73
+ result = transform(<<~ERB).strip
74
+ <%= render(FooComponent.new(bar: 'baz')) %>
75
+ ERB
76
+
77
+ expect(result).to eq('<FooComponent bar={"baz"} />')
78
+ end
79
+
80
+ it 'handles component renders with empty blocks' do
81
+ result = transform(<<~ERB).strip
82
+ <%= render(FooComponent.new(bar: 'baz')) do %>
83
+ <% end %>
84
+ ERB
85
+
86
+ expect(result).to eq(<<~RUX.strip)
87
+ <FooComponent bar={"baz"}>
88
+ </FooComponent>
89
+ RUX
90
+ end
91
+
92
+ it 'handles component renders with blocks that contain strings' do
93
+ result = transform(<<~ERB).strip
94
+ <%= render(FooComponent.new(bar: 'baz')) do %>
95
+ foobar
96
+ <% end %>
97
+ ERB
98
+
99
+ expect(result).to eq(<<~RUX.strip)
100
+ <FooComponent bar={"baz"}>
101
+ {"foobar"}
102
+ </FooComponent>
103
+ RUX
104
+ end
105
+
106
+ it 'handles component renders with blocks that contain code' do
107
+ result = transform(<<~ERB).strip
108
+ <%= render(FooComponent.new(bar: 'baz')) do %>
109
+ <%= foobar %>
110
+ <% end %>
111
+ ERB
112
+
113
+ expect(result).to eq(<<~RUX.strip)
114
+ <FooComponent bar={"baz"}>
115
+ {foobar}
116
+ </FooComponent>
117
+ RUX
118
+ end
119
+
120
+ it 'handles component renders with blocks that contain strings and code' do
121
+ result = transform(<<~ERB).strip
122
+ <%= render(FooComponent.new(bar: 'baz')) do %>
123
+ <%= foobar %> foobaz
124
+ <% end %>
125
+ ERB
126
+
127
+ expect(result).to eq(<<~RUX.strip)
128
+ <FooComponent bar={"baz"}>
129
+ {foobar + "foobaz"}
130
+ </FooComponent>
131
+ RUX
132
+ end
133
+
134
+ it 'handles component renders with blocks that have a block arg' do
135
+ result = transform(<<~ERB).strip
136
+ <%= render(FooComponent.new(bar: 'baz')) do |component| %>
137
+ <% end %>
138
+ ERB
139
+
140
+ expect(result).to eq(<<~RUX.strip)
141
+ <FooComponent bar={"baz"} as={"component"}>
142
+ </FooComponent>
143
+ RUX
144
+ end
145
+
146
+ it 'handles component renders with blocks that have a block arg and code' do
147
+ result = transform(<<~ERB).strip
148
+ <%= render(FooComponent.new(bar: 'baz')) do |component| %>
149
+ <% component.sidebar do %>
150
+ <% end %>
151
+ <% end %>
152
+ ERB
153
+
154
+ expect(result).to eq(<<~RUX.strip)
155
+ <FooComponent bar={"baz"} as={"component"}>
156
+ {component.sidebar do
157
+ end}
158
+ </FooComponent>
159
+ RUX
160
+ end
161
+
162
+ it 'handles component renders with blocks that have a block arg and multiple expressions' do
163
+ result = transform(<<~ERB).strip
164
+ <%= render(FooComponent.new(bar: 'baz')) do |component| %>
165
+ <% component.sidebar do %>
166
+ <% end %>
167
+ <% component.main do %>
168
+ <% end %>
169
+ <% end %>
170
+ ERB
171
+
172
+ expect(result).to eq(<<~RUX.strip)
173
+ <FooComponent bar={"baz"} as={"component"}>
174
+ {component.sidebar do
175
+ end
176
+ component.main do
177
+ end}
178
+ </FooComponent>
179
+ RUX
180
+ end
181
+
182
+ it 'handles nesting other components inside blocks' do
183
+ result = transform(<<~ERB).strip
184
+ <%= render(FooComponent.new(bar: 'baz')) do |component| %>
185
+ <% component.sidebar do %>
186
+ <%= render(SidebarComponent.new) %>
187
+ <% end %>
188
+ <% component.main do %>
189
+ <%= render(MainComponent.new) %>
190
+ <% end %>
191
+ <% end %>
192
+ ERB
193
+
194
+ expect(result).to eq(<<~RUX.strip)
195
+ <FooComponent bar={"baz"} as={"component"}>
196
+ {component.sidebar do
197
+ <SidebarComponent />
198
+ end
199
+ component.main do
200
+ <MainComponent />
201
+ end}
202
+ </FooComponent>
203
+ RUX
204
+ end
205
+
206
+ it 'handles nesting other components with arguments inside blocks' do
207
+ result = transform(<<~ERB).strip
208
+ <%= render(FooComponent.new(bar: 'baz')) do |component| %>
209
+ <% component.sidebar do %>
210
+ <%= render(SidebarComponent.new(bar: 'baz')) %>
211
+ <% end %>
212
+ <% component.main do %>
213
+ <%= render(MainComponent.new(bar: 'baz')) %>
214
+ <% end %>
215
+ <% end %>
216
+ ERB
217
+
218
+ expect(result).to eq(<<~RUX.strip)
219
+ <FooComponent bar={"baz"} as={"component"}>
220
+ {component.sidebar do
221
+ <SidebarComponent bar={"baz"} />
222
+ end
223
+ component.main do
224
+ <MainComponent bar={"baz"} />
225
+ end}
226
+ </FooComponent>
227
+ RUX
228
+ end
229
+
230
+ it 'handles nesting other components with blocks' do
231
+ result = transform(<<~ERB).strip
232
+ <%= render(FooComponent.new(bar: 'baz')) do |component| %>
233
+ <% component.sidebar do %>
234
+ <%= render(SidebarComponent.new(bar: 'baz')) do %>
235
+ <% end %>
236
+ <% end %>
237
+ <% component.main do %>
238
+ <%= render(MainComponent.new(bar: 'baz')) do %>
239
+ <% end %>
240
+ <% end %>
241
+ <% end %>
242
+ ERB
243
+
244
+ expect(result).to eq(<<~RUX.strip)
245
+ <FooComponent bar={"baz"} as={"component"}>
246
+ {component.sidebar do
247
+ <SidebarComponent bar={"baz"}>
248
+ </SidebarComponent>
249
+ end
250
+ component.main do
251
+ <MainComponent bar={"baz"}>
252
+ </MainComponent>
253
+ end}
254
+ </FooComponent>
255
+ RUX
256
+ end
257
+ end
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: erb2rux
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Cameron Dutro
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-09-12 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: actionview
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '6.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '6.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: parser
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: unparser
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.6'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.6'
55
+ description: Automatically convert .html.erb files into .rux files.
56
+ email:
57
+ - camertron@gmail.com
58
+ executables:
59
+ - erb2rux
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - Gemfile
64
+ - LICENSE
65
+ - README.md
66
+ - Rakefile
67
+ - bin/erb2rux
68
+ - erb2rux.gemspec
69
+ - lib/erb2rux.rb
70
+ - lib/erb2rux/component_render.rb
71
+ - lib/erb2rux/transformer.rb
72
+ - lib/erb2rux/version.rb
73
+ - spec/spec_helper.rb
74
+ - spec/transformer_spec.rb
75
+ homepage: http://github.com/camertron/erb2rux
76
+ licenses: []
77
+ metadata: {}
78
+ post_install_message:
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 3.2.22
94
+ signing_key:
95
+ specification_version: 4
96
+ summary: Automatically convert .html.erb files into .rux files.
97
+ test_files: []