erb2rux 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Gemfile +9 -0
- data/LICENSE +21 -0
- data/README.md +45 -0
- data/Rakefile +14 -0
- data/bin/erb2rux +77 -0
- data/erb2rux.gemspec +22 -0
- data/lib/erb2rux/component_render.rb +49 -0
- data/lib/erb2rux/transformer.rb +405 -0
- data/lib/erb2rux/version.rb +3 -0
- data/lib/erb2rux.rb +4 -0
- data/spec/spec_helper.rb +16 -0
- data/spec/transformer_spec.rb +257 -0
- metadata +97 -0
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
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
|
data/lib/erb2rux.rb
ADDED
data/spec/spec_helper.rb
ADDED
@@ -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: []
|