herbst 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +41 -7
- data/Rakefile +11 -1
- data/exe/herbst +6 -0
- data/herbst.gemspec +5 -3
- data/lib/herbst/builder.rb +358 -0
- data/lib/herbst/cli.rb +276 -0
- data/lib/herbst/converter.rb +182 -0
- data/lib/herbst/haml/attributes.rb +161 -0
- data/lib/herbst/haml/converter.rb +244 -0
- data/lib/herbst/haml/filters.rb +61 -0
- data/lib/herbst/haml.rb +37 -0
- data/lib/herbst/identity_printer.rb +438 -0
- data/lib/herbst/printer.rb +54 -0
- data/lib/herbst/round_trip.rb +67 -0
- data/lib/herbst/slim/converter.rb +452 -0
- data/lib/herbst/slim.rb +30 -0
- data/lib/herbst/version.rb +1 -1
- data/lib/herbst.rb +22 -0
- data/sig/herbst/builder.rbs +110 -0
- data/sig/herbst/cli.rbs +101 -0
- data/sig/herbst/converter.rbs +59 -0
- data/sig/herbst/haml/attributes.rbs +49 -0
- data/sig/herbst/haml/converter.rbs +78 -0
- data/sig/herbst/haml/filters.rbs +25 -0
- data/sig/herbst/haml.rbs +22 -0
- data/sig/herbst/identity_printer.rbs +206 -0
- data/sig/herbst/printer.rbs +25 -0
- data/sig/herbst/round_trip.rbs +26 -0
- data/sig/herbst/slim/converter.rbs +123 -0
- data/sig/herbst/slim.rbs +17 -0
- data/sig/herbst/version.rbs +5 -0
- data/sig/herbst.rbs +11 -1
- metadata +34 -6
data/lib/herbst/cli.rb
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Herbst
|
|
6
|
+
# The `herbst` command.
|
|
7
|
+
#
|
|
8
|
+
# herbst convert app/views/posts/show.html.haml # prints the ERB
|
|
9
|
+
# herbst convert app/views # writes show.html.erb next to show.html.haml or .slim
|
|
10
|
+
#
|
|
11
|
+
# A single file is printed to stdout unless `--write` is given. Directories and multiple
|
|
12
|
+
# files are written next to their sources, replacing the `.haml` or `.slim` extension with `.erb`.
|
|
13
|
+
# Existing ERB files are skipped unless `--force` is given. A file without a format gets
|
|
14
|
+
# `html`, so `show.haml` becomes `show.html.erb`, while `welcome.text.haml` keeps its format.
|
|
15
|
+
#
|
|
16
|
+
class CLI
|
|
17
|
+
class Conversion
|
|
18
|
+
attr_reader :path #: String
|
|
19
|
+
attr_reader :warnings #: Array[String]
|
|
20
|
+
attr_reader :error #: String?
|
|
21
|
+
attr_reader :difference #: String?
|
|
22
|
+
attr_accessor :erb #: String?
|
|
23
|
+
|
|
24
|
+
#: (String, ?erb: String?, ?warnings: Array[String], ?error: String?, ?difference: String?) -> void
|
|
25
|
+
def initialize(path, erb: nil, warnings: [], error: nil, difference: nil)
|
|
26
|
+
@path = path
|
|
27
|
+
@erb = erb
|
|
28
|
+
@warnings = warnings
|
|
29
|
+
@error = error
|
|
30
|
+
@difference = difference
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
#: () -> bool
|
|
34
|
+
def failed?
|
|
35
|
+
!error.nil? || !difference.nil?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @rbs @arguments: Array[String]
|
|
40
|
+
# @rbs @stdout: IO
|
|
41
|
+
# @rbs @stderr: IO
|
|
42
|
+
# @rbs @format: bool
|
|
43
|
+
# @rbs @verify: bool
|
|
44
|
+
# @rbs @write: bool
|
|
45
|
+
# @rbs @force: bool
|
|
46
|
+
# @rbs @default_format: String?
|
|
47
|
+
# @rbs @help: bool
|
|
48
|
+
# @rbs @version: bool
|
|
49
|
+
# @rbs @option_parser: OptionParser?
|
|
50
|
+
|
|
51
|
+
#: (Array[String], ?stdout: IO, ?stderr: IO) -> void
|
|
52
|
+
def initialize(arguments, stdout: $stdout, stderr: $stderr)
|
|
53
|
+
@arguments = arguments
|
|
54
|
+
@stdout = stdout
|
|
55
|
+
@stderr = stderr
|
|
56
|
+
@format = true
|
|
57
|
+
@verify = true
|
|
58
|
+
@write = false
|
|
59
|
+
@force = false
|
|
60
|
+
@default_format = "html"
|
|
61
|
+
@help = false
|
|
62
|
+
@version = false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
#: () -> Integer
|
|
66
|
+
def run
|
|
67
|
+
command, *paths = option_parser.parse(@arguments)
|
|
68
|
+
|
|
69
|
+
return help if @help
|
|
70
|
+
return version if @version
|
|
71
|
+
|
|
72
|
+
case command
|
|
73
|
+
when "convert"
|
|
74
|
+
paths.empty? ? help(1, "Missing path: pass the Haml or Slim files, or directories, to convert.") : convert(paths)
|
|
75
|
+
when "help"
|
|
76
|
+
help
|
|
77
|
+
when "version"
|
|
78
|
+
version
|
|
79
|
+
when String
|
|
80
|
+
help(1, "Unknown command: '#{command}'")
|
|
81
|
+
else
|
|
82
|
+
help(1)
|
|
83
|
+
end
|
|
84
|
+
rescue OptionParser::ParseError => e
|
|
85
|
+
help(1, e.message)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
#: (Array[String]) -> Integer
|
|
91
|
+
def convert(paths)
|
|
92
|
+
missing = paths.reject { |path| File.exist?(path) }
|
|
93
|
+
return fail_with("#{missing.join(", ")}: No such file or directory") unless missing.empty?
|
|
94
|
+
|
|
95
|
+
files = paths.flat_map { |path| File.directory?(path) ? Dir[File.join(path, "**", "*.{haml,slim}")] : [path] }.sort
|
|
96
|
+
return fail_with("No .haml or .slim files found in #{paths.join(", ")}") if files.empty?
|
|
97
|
+
|
|
98
|
+
conversions = files.map { |file| convert_file(file) }
|
|
99
|
+
format(conversions) if @format
|
|
100
|
+
|
|
101
|
+
write = @write || paths.size > 1 || paths.any? { |path| File.directory?(path) }
|
|
102
|
+
write ? write_files(conversions) : print_file(conversions.fetch(0))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
#: () -> OptionParser
|
|
106
|
+
def option_parser
|
|
107
|
+
@option_parser ||= OptionParser.new do |parser|
|
|
108
|
+
parser.banner = ""
|
|
109
|
+
|
|
110
|
+
parser.on_tail("-v", "--version", "Show the version") { @version = true }
|
|
111
|
+
parser.on_tail("-h", "--help", "Show this message") { @help = true }
|
|
112
|
+
|
|
113
|
+
parser.on("--force", "Overwrite existing ERB files (for convert command)") { @force = true }
|
|
114
|
+
parser.on("--no-format", "Don't run the Herb formatter on the output (for convert command)") { @format = false }
|
|
115
|
+
|
|
116
|
+
parser.on("--no-verify", "Don't check the ERB parses back into the converted tree (for convert command)") do
|
|
117
|
+
@verify = false
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
parser.on("--write", "Write a single file next to its source instead of printing it (for convert command)") do
|
|
121
|
+
@write = true
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
parser.on("--default-format FORMAT", "Format for files without one (default: html) (for convert command)") do |format|
|
|
125
|
+
@default_format = format
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
parser.on("--no-default-format", "Don't add a format to files without one, as in show.erb (for convert command)") do
|
|
129
|
+
@default_format = nil
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
#: (?Integer, ?String?) -> Integer
|
|
135
|
+
def help(exit_code = 0, message = nil)
|
|
136
|
+
io = exit_code.zero? ? @stdout : @stderr
|
|
137
|
+
|
|
138
|
+
io.puts message, "" if message
|
|
139
|
+
io.puts <<~HELP
|
|
140
|
+
Herbst 🍂 Unified syntax tree for HTML templating languages.
|
|
141
|
+
|
|
142
|
+
Usage:
|
|
143
|
+
bundle exec herbst [command] [options]
|
|
144
|
+
|
|
145
|
+
Commands:
|
|
146
|
+
bundle exec herbst convert [paths] Convert Haml and Slim files, or every one in a directory, to ERB.
|
|
147
|
+
bundle exec herbst help Show this message.
|
|
148
|
+
bundle exec herbst version Prints the versions of Herbst, Herb, Haml and Slim.
|
|
149
|
+
|
|
150
|
+
Converting:
|
|
151
|
+
A single file is printed to stdout, unless `--write` is passed:
|
|
152
|
+
bundle exec herbst convert app/views/posts/show.html.haml
|
|
153
|
+
|
|
154
|
+
Directories, and several files at once, are written next to their sources:
|
|
155
|
+
bundle exec herbst convert app/views
|
|
156
|
+
|
|
157
|
+
Existing ERB files are skipped unless `--force` is passed. The Haml and Slim files are kept.
|
|
158
|
+
|
|
159
|
+
A file without a format gets html, so show.haml becomes show.html.erb. Pass
|
|
160
|
+
`--default-format FORMAT` for another format, or `--no-default-format` to write show.erb.
|
|
161
|
+
|
|
162
|
+
Options:
|
|
163
|
+
HELP
|
|
164
|
+
io.puts option_parser.to_s.delete_prefix("\n").gsub(/^ /, " "), ""
|
|
165
|
+
|
|
166
|
+
exit_code
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
#: () -> Integer
|
|
170
|
+
def version
|
|
171
|
+
versions = ["herbst gem v#{VERSION}", "herb gem v#{Herb::VERSION}"]
|
|
172
|
+
|
|
173
|
+
["haml", "slim"].each do |name|
|
|
174
|
+
version = Gem::Specification.find_all_by_name(name).map(&:version).max # steep:ignore
|
|
175
|
+
versions << "#{name} gem v#{version}" if version
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
@stdout.puts versions.join(", ")
|
|
179
|
+
|
|
180
|
+
0
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
#: (String) -> Conversion
|
|
184
|
+
def convert_file(file)
|
|
185
|
+
converter = converter_for(file)
|
|
186
|
+
document = converter.convert(File.read(file, encoding: "UTF-8"))
|
|
187
|
+
erb = IdentityPrinter.print(document)
|
|
188
|
+
difference = RoundTrip.difference(document, erb) if @verify
|
|
189
|
+
|
|
190
|
+
Conversion.new(file, erb: erb, warnings: converter.warnings, difference: difference)
|
|
191
|
+
rescue Herbst::Error => e
|
|
192
|
+
Conversion.new(file, error: e.message)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
#: (String) -> Herbst::Converter
|
|
196
|
+
def converter_for(path)
|
|
197
|
+
File.extname(path) == ".slim" ? Slim::Converter.new : Haml::Converter.new
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
#: (Array[Conversion]) -> void
|
|
201
|
+
def format(conversions)
|
|
202
|
+
converted = conversions.select(&:erb)
|
|
203
|
+
formatted = Printer.format_all(converted.map { |conversion| conversion.erb.to_s })
|
|
204
|
+
|
|
205
|
+
converted.zip(formatted) { |conversion, erb| conversion.erb = erb }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
#: (Conversion) -> Integer
|
|
209
|
+
def print_file(conversion)
|
|
210
|
+
report(conversion)
|
|
211
|
+
@stdout.print(conversion.erb) if conversion.erb
|
|
212
|
+
|
|
213
|
+
conversion.failed? ? 1 : 0
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
#: (Array[Conversion]) -> Integer
|
|
217
|
+
def write_files(conversions)
|
|
218
|
+
written = 0
|
|
219
|
+
skipped = 0
|
|
220
|
+
|
|
221
|
+
conversions.each do |conversion|
|
|
222
|
+
report(conversion)
|
|
223
|
+
|
|
224
|
+
erb = conversion.erb
|
|
225
|
+
next unless erb
|
|
226
|
+
|
|
227
|
+
target = target_path(conversion.path)
|
|
228
|
+
|
|
229
|
+
if File.exist?(target) && !@force
|
|
230
|
+
@stderr.puts "#{conversion.path}: #{target} already exists, skipped (use --force to overwrite)"
|
|
231
|
+
skipped += 1
|
|
232
|
+
else
|
|
233
|
+
File.write(target, erb)
|
|
234
|
+
written += 1
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
failed = conversions.count(&:failed?)
|
|
239
|
+
@stderr.puts summary(conversions.size, written, skipped, failed)
|
|
240
|
+
|
|
241
|
+
failed.positive? ? 1 : 0
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
#: (String) -> String
|
|
245
|
+
def target_path(path)
|
|
246
|
+
base = path.sub(/\.(haml|slim)\z/, "")
|
|
247
|
+
base = "#{base}.#{@default_format}" if @default_format && !File.basename(base).include?(".")
|
|
248
|
+
|
|
249
|
+
"#{base}.erb"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
#: (Conversion) -> void
|
|
253
|
+
def report(conversion)
|
|
254
|
+
conversion.warnings.each { |warning| @stderr.puts "#{conversion.path}: #{warning}" }
|
|
255
|
+
@stderr.puts "#{conversion.path}: #{conversion.error}" if conversion.error
|
|
256
|
+
@stderr.puts "#{conversion.path}: #{conversion.difference}" if conversion.difference
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
#: (Integer, Integer, Integer, Integer) -> String
|
|
260
|
+
def summary(total, written, skipped, failed)
|
|
261
|
+
parts = ["Converted #{written} of #{total} #{total == 1 ? "file" : "files"}"]
|
|
262
|
+
|
|
263
|
+
parts << "#{skipped} skipped" if skipped.positive?
|
|
264
|
+
parts << "#{failed} failed" if failed.positive?
|
|
265
|
+
|
|
266
|
+
"#{parts.join(", ")}."
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
#: (String) -> Integer
|
|
270
|
+
def fail_with(message)
|
|
271
|
+
@stderr.puts message
|
|
272
|
+
|
|
273
|
+
1
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Herbst
|
|
4
|
+
class Converter
|
|
5
|
+
include Builder
|
|
6
|
+
|
|
7
|
+
INDENT = " " #: String
|
|
8
|
+
|
|
9
|
+
# @rbs!
|
|
10
|
+
# type clause = [String, String?, Array[Herb::AST::Node], Integer?]
|
|
11
|
+
|
|
12
|
+
attr_reader :warnings #: Array[String]
|
|
13
|
+
|
|
14
|
+
# @rbs @markup: bool
|
|
15
|
+
|
|
16
|
+
#: () -> void
|
|
17
|
+
def initialize
|
|
18
|
+
@warnings = []
|
|
19
|
+
@markup = false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
#: (String) -> Herb::AST::DocumentNode
|
|
23
|
+
def convert(source)
|
|
24
|
+
raise NotImplementedError, "#{self.class} must implement `convert`"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
#: (String) -> Array[Herb::AST::Node]
|
|
28
|
+
def text_nodes(value)
|
|
29
|
+
@markup = true if value.match?(%r{<[\p{L}/!?]})
|
|
30
|
+
|
|
31
|
+
value.split(/(<%|%>)/).reject(&:empty?).map do |part|
|
|
32
|
+
case part
|
|
33
|
+
when "<%"
|
|
34
|
+
erb('"<%"', opening: "<%==")
|
|
35
|
+
when "%>"
|
|
36
|
+
erb('"%\>"', opening: "<%==")
|
|
37
|
+
else
|
|
38
|
+
text(part)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#: (Integer?, String) -> void
|
|
44
|
+
def warn(line, message)
|
|
45
|
+
@warnings << (line ? "line #{line}: #{message}" : message)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
#: (Herb::AST::DocumentNode) -> Herb::AST::DocumentNode
|
|
51
|
+
def finish(document)
|
|
52
|
+
document = merge_texts!(document)
|
|
53
|
+
return document unless @markup
|
|
54
|
+
|
|
55
|
+
Herb.parse(IdentityPrinter.print(document), track_whitespace: true).value
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
#: (Integer) -> String
|
|
59
|
+
def newline(depth)
|
|
60
|
+
"\n#{INDENT * depth}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
#: (String) -> String?
|
|
64
|
+
def keyword(code)
|
|
65
|
+
code[/\A\s*(if|unless|case|begin|while|until|for|elsif|else|when|in|rescue|ensure)\b/, 1]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
#: (String, String?, Array[Herb::AST::Node], Array[clause], Integer, ?line: Integer?) -> Herb::AST::Node
|
|
69
|
+
def control(code, keyword, body, clauses, depth, line: nil)
|
|
70
|
+
case keyword
|
|
71
|
+
when "if"
|
|
72
|
+
control_if(code, body, clauses)
|
|
73
|
+
when "unless"
|
|
74
|
+
control_unless(code, body, clauses)
|
|
75
|
+
when "case"
|
|
76
|
+
control_case(code, body, clauses, depth, line)
|
|
77
|
+
when "begin"
|
|
78
|
+
control_begin(code, body, clauses)
|
|
79
|
+
when "while"
|
|
80
|
+
clauses.empty? ? erb_while(code, body) : control_block(code, body, clauses)
|
|
81
|
+
when "until"
|
|
82
|
+
clauses.empty? ? erb_until(code, body) : control_block(code, body, clauses)
|
|
83
|
+
when "for"
|
|
84
|
+
clauses.empty? ? erb_for(code, body) : control_block(code, body, clauses)
|
|
85
|
+
else
|
|
86
|
+
control_block(code, body, clauses)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
#: (String, Array[Herb::AST::Node], Array[clause]) -> Herb::AST::ERBIfNode
|
|
91
|
+
def control_if(code, body, clauses)
|
|
92
|
+
subsequent = nil #: (Herb::AST::ERBIfNode | Herb::AST::ERBElseNode)?
|
|
93
|
+
|
|
94
|
+
clauses.reverse_each do |clause_code, clause_keyword, clause_body, clause_line|
|
|
95
|
+
subsequent =
|
|
96
|
+
case clause_keyword
|
|
97
|
+
when "else"
|
|
98
|
+
erb_else(clause_body)
|
|
99
|
+
when "elsif"
|
|
100
|
+
erb_if(clause_code, clause_body, subsequent: subsequent, end_node: nil)
|
|
101
|
+
else
|
|
102
|
+
unsupported_clause(clause_keyword, "if", clause_line)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
erb_if(code, body, subsequent: subsequent)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
#: (String, Array[Herb::AST::Node], Array[clause]) -> Herb::AST::ERBUnlessNode
|
|
110
|
+
def control_unless(code, body, clauses)
|
|
111
|
+
clauses.each do |_code, clause_keyword, _body, clause_line|
|
|
112
|
+
unsupported_clause(clause_keyword, "unless", clause_line) unless clause_keyword == "else"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
else_clause = clauses.find { |clause| clause[1] == "else" }
|
|
116
|
+
|
|
117
|
+
erb_unless(code, body, else_clause: else_clause && erb_else(else_clause[2]))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
#: (String, Array[Herb::AST::Node], Array[clause], Integer, Integer?) -> Herb::AST::ERBCaseNode
|
|
121
|
+
def control_case(code, body, clauses, depth, line)
|
|
122
|
+
unless body.all? { |node| node.is_a?(Herb::AST::HTMLTextNode) && node.content.to_s.strip.empty? }
|
|
123
|
+
warn(line, "content between `case` and the first `when` was dropped")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
conditions = clauses.filter_map do |clause_code, clause_keyword, clause_body, clause_line|
|
|
127
|
+
case clause_keyword
|
|
128
|
+
when "when"
|
|
129
|
+
erb_when(clause_code, clause_body)
|
|
130
|
+
when "else"
|
|
131
|
+
nil
|
|
132
|
+
else
|
|
133
|
+
unsupported_clause(clause_keyword, "case", clause_line)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
else_clause = clauses.find { |clause| clause[1] == "else" }
|
|
138
|
+
node = erb_case(code, conditions, else_clause: else_clause && erb_else(else_clause[2]))
|
|
139
|
+
first = conditions.first&.statements&.first
|
|
140
|
+
|
|
141
|
+
node.children << text(newline(depth)) if first.is_a?(Herb::AST::HTMLTextNode) && first.content.to_s.start_with?("\n")
|
|
142
|
+
|
|
143
|
+
node
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
#: (String, Array[Herb::AST::Node], Array[clause]) -> Herb::AST::ERBBeginNode
|
|
147
|
+
def control_begin(code, body, clauses)
|
|
148
|
+
else_clause = clauses.find { |clause| clause[1] == "else" }
|
|
149
|
+
ensure_clause = clauses.find { |clause| clause[1] == "ensure" }
|
|
150
|
+
|
|
151
|
+
rescue_clause = nil #: Herb::AST::ERBRescueNode?
|
|
152
|
+
|
|
153
|
+
clauses.select { |clause| clause[1] == "rescue" }.reverse_each do |clause_code, _keyword, clause_body, _line|
|
|
154
|
+
rescue_clause = erb_rescue(clause_code, clause_body, subsequent: rescue_clause)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
erb_begin(
|
|
158
|
+
code, body,
|
|
159
|
+
rescue_clause: rescue_clause,
|
|
160
|
+
else_clause: else_clause && erb_else(else_clause[2]),
|
|
161
|
+
ensure_clause: ensure_clause && erb_ensure(ensure_clause[2])
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
#: (String, Array[Herb::AST::Node], Array[clause]) -> Herb::AST::ERBBlockNode
|
|
166
|
+
def control_block(code, body, clauses)
|
|
167
|
+
body = body.dup
|
|
168
|
+
|
|
169
|
+
clauses.each do |clause_code, _keyword, clause_body, _line|
|
|
170
|
+
body << erb(clause_code, opening: "<%")
|
|
171
|
+
body.concat(clause_body)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
erb_block(code, body)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
#: (String?, String, Integer?) -> bot
|
|
178
|
+
def unsupported_clause(clause_keyword, keyword, line)
|
|
179
|
+
raise Herbst::UnsupportedError.new("`#{clause_keyword}` is not supported inside `#{keyword}`", line)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi/escape"
|
|
4
|
+
require "temple"
|
|
5
|
+
|
|
6
|
+
module Herbst
|
|
7
|
+
module Haml
|
|
8
|
+
class Attributes
|
|
9
|
+
include Builder
|
|
10
|
+
|
|
11
|
+
# @rbs @warnings: Array[String]
|
|
12
|
+
|
|
13
|
+
#: (Array[String]) -> void
|
|
14
|
+
def initialize(warnings)
|
|
15
|
+
@warnings = warnings
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
#: (::Haml::Parser::ParseNode) -> Array[Herb::AST::Node]
|
|
19
|
+
def compile(node)
|
|
20
|
+
value = node.value
|
|
21
|
+
dynamic = value[:dynamic_attributes]
|
|
22
|
+
sources = [dynamic.new, dynamic.old].compact
|
|
23
|
+
|
|
24
|
+
if value[:object_ref] != :nil
|
|
25
|
+
warn(node, "object references (`[#{value[:object_ref]}]`) are not supported and were dropped")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
hashes = sources.filter_map { |source| ::Haml::AttributeParser.parse(source) }
|
|
29
|
+
return fallback(node, sources) if hashes.size < sources.size || hashes.any? { |hash| hash.any? { |_key, code| code.empty? } }
|
|
30
|
+
|
|
31
|
+
values = Hash.new { |hash, key| hash[key] = [] } #: Hash[String, Array[[Symbol, untyped]]]
|
|
32
|
+
value[:attributes].each { |key, static| values[key.to_s] << [:static, static] }
|
|
33
|
+
hashes.each { |hash| hash.each { |key, code| values[key.to_s] << [:dynamic, code] } }
|
|
34
|
+
|
|
35
|
+
values.keys.sort.flat_map { |key| compile_attribute(key, values[key]) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
#: (String, Array[[Symbol, untyped]]) -> Array[Herb::AST::Node]
|
|
41
|
+
def compile_attribute(key, values)
|
|
42
|
+
# Literal-only Ruby (checked by Temple::StaticAnalyzer) is folded into static values.
|
|
43
|
+
values = values.map do |type, value|
|
|
44
|
+
type == :dynamic && literal?(value) ? [:static, eval(value)] : [type, value] # rubocop:disable Security/Eval
|
|
45
|
+
end #: Array[[Symbol, untyped]]
|
|
46
|
+
|
|
47
|
+
case key
|
|
48
|
+
when "class"
|
|
49
|
+
compile_joined(key, values, " ") do |code|
|
|
50
|
+
array_literal?(code) ? "#{code}.flatten.select(&:itself).join(\" \")" : code
|
|
51
|
+
end
|
|
52
|
+
when "id"
|
|
53
|
+
compile_joined(key, values, "_") do |code|
|
|
54
|
+
array_literal?(code) ? "#{code}.flatten.select(&:itself).join(\"_\")" : code
|
|
55
|
+
end
|
|
56
|
+
when "data", "aria"
|
|
57
|
+
compile_hash(key, values.fetch(-1))
|
|
58
|
+
else
|
|
59
|
+
compile_single(key, values.fetch(-1))
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
#: (String, Array[[Symbol, untyped]], String) { (String) -> String } -> Array[Herb::AST::Node]
|
|
64
|
+
def compile_joined(key, values, separator)
|
|
65
|
+
statics = values.filter_map { |type, value| value if type == :static }
|
|
66
|
+
statics = statics.flat_map { |value| Array(value).flatten }.select(&:itself).map(&:to_s)
|
|
67
|
+
statics = statics.flat_map { |value| value.split(separator) } if separator == " "
|
|
68
|
+
dynamics = values.filter_map { |type, code| yield(code) if type == :dynamic }
|
|
69
|
+
|
|
70
|
+
return [] if statics.empty? && dynamics.empty?
|
|
71
|
+
|
|
72
|
+
children = [] #: Array[Herb::AST::Node]
|
|
73
|
+
children << literal(escape(statics.uniq.join(separator))) unless statics.empty?
|
|
74
|
+
dynamics.each do |code|
|
|
75
|
+
children << literal(separator) unless children.empty?
|
|
76
|
+
children << erb(code)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
[attribute(key, children)]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
#: (String, [Symbol, untyped]) -> Array[Herb::AST::Node]
|
|
83
|
+
def compile_hash(prefix, pair)
|
|
84
|
+
type, value = pair
|
|
85
|
+
|
|
86
|
+
hash = type == :static ? value : ::Haml::AttributeParser.parse(value)
|
|
87
|
+
|
|
88
|
+
unless hash.is_a?(Hash)
|
|
89
|
+
return compile_single(prefix, [type, value]) unless type == :dynamic
|
|
90
|
+
|
|
91
|
+
return [erb("tag.attributes(#{prefix}: #{value})")]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
hash.sort_by { |key, _| key.to_s }.flat_map do |key, nested|
|
|
95
|
+
name = "#{prefix}-#{key.to_s.tr("_", "-")}"
|
|
96
|
+
nested_type = type == :static || literal?(nested) ? :static : :dynamic
|
|
97
|
+
nested_value = type == :dynamic && nested_type == :static ? eval(nested) : nested # rubocop:disable Security/Eval
|
|
98
|
+
|
|
99
|
+
if nested_value.is_a?(Hash) || (nested_type == :dynamic && ::Haml::AttributeParser.parse(nested).is_a?(Hash))
|
|
100
|
+
compile_hash(name, [nested_type, nested_value])
|
|
101
|
+
else
|
|
102
|
+
compile_single(name, [nested_type, nested_value])
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
#: (String, [Symbol, untyped]) -> Array[Herb::AST::Node]
|
|
108
|
+
def compile_single(key, pair)
|
|
109
|
+
type, value = pair
|
|
110
|
+
|
|
111
|
+
if type == :static
|
|
112
|
+
case value
|
|
113
|
+
when true
|
|
114
|
+
[attribute(key)]
|
|
115
|
+
when false, nil
|
|
116
|
+
[]
|
|
117
|
+
else
|
|
118
|
+
[attribute(key, [literal(escape(value.to_s))])]
|
|
119
|
+
end
|
|
120
|
+
elsif boolean?(key)
|
|
121
|
+
[erb("#{key.inspect} if #{value}")]
|
|
122
|
+
else
|
|
123
|
+
[attribute(key, [erb(value)])]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
#: (::Haml::Parser::ParseNode, Array[String]) -> Array[Herb::AST::Node]
|
|
128
|
+
def fallback(node, sources)
|
|
129
|
+
arguments = sources.map { |source| source.strip.delete_prefix("{").delete_suffix("}").strip.delete_suffix(",") }
|
|
130
|
+
warn(node, "dynamic attributes `#{sources.join(" ")}` were converted to `tag.attributes`, which requires Rails 7.1+")
|
|
131
|
+
|
|
132
|
+
node.value[:attributes].sort.flat_map { |key, value| compile_single(key, [:static, value]) } + [erb("tag.attributes(#{arguments.join(", ")})")]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
#: (String) -> bool
|
|
136
|
+
def literal?(code)
|
|
137
|
+
Temple::StaticAnalyzer.static?(code)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
#: (String) -> bool
|
|
141
|
+
def array_literal?(code)
|
|
142
|
+
code.strip.start_with?("[")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
#: (String) -> bool
|
|
146
|
+
def boolean?(key)
|
|
147
|
+
::Haml::BOOLEAN_ATTRIBUTES.include?(key)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
#: (String) -> String
|
|
151
|
+
def escape(value)
|
|
152
|
+
CGI.escapeHTML(value).gsub("<%", "<%")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
#: (::Haml::Parser::ParseNode, String) -> void
|
|
156
|
+
def warn(node, message)
|
|
157
|
+
@warnings << "line #{node.line}: #{message}"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|