super-slim 3.0.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 +7 -0
- data/CHANGELOG.md +118 -0
- data/LICENSE +21 -0
- data/LICENSE-slim +22 -0
- data/README.md +217 -0
- data/config/rubocop.yml +212 -0
- data/exe/super-slim +6 -0
- data/lib/super-slim.rb +4 -0
- data/lib/super_slim/ast.rb +34 -0
- data/lib/super_slim/cli.rb +71 -0
- data/lib/super_slim/compiler.rb +467 -0
- data/lib/super_slim/formatter/line_wrapper.rb +131 -0
- data/lib/super_slim/formatter.rb +125 -0
- data/lib/super_slim/linter/rules.rb +585 -0
- data/lib/super_slim/linter.rb +81 -0
- data/lib/super_slim/parallel.rb +36 -0
- data/lib/super_slim/parser.rb +556 -0
- data/lib/super_slim/rails_handler.rb +31 -0
- data/lib/super_slim/railtie.rb +12 -0
- data/lib/super_slim/rubocop.rb +128 -0
- data/lib/super_slim/runtime.rb +103 -0
- data/lib/super_slim/shortcuts.rb +28 -0
- data/lib/super_slim/version.rb +5 -0
- data/lib/super_slim.rb +53 -0
- metadata +86 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../super_slim"
|
|
4
|
+
require_relative "linter"
|
|
5
|
+
require_relative "parallel"
|
|
6
|
+
|
|
7
|
+
module SuperSlim
|
|
8
|
+
class CLI
|
|
9
|
+
USAGE = <<~TEXT
|
|
10
|
+
Usage: super-slim lint [PATH…] report what breaks the rules of .super-slim.yml
|
|
11
|
+
super-slim format [--check] [PATH…] rewrite templates in their canonical layout (alias: fmt)
|
|
12
|
+
super-slim version
|
|
13
|
+
TEXT
|
|
14
|
+
SKIPPED_DIRECTORIES = %r{(\A|/)(\.git|node_modules|tmp|vendor)/}
|
|
15
|
+
EX_USAGE = 64
|
|
16
|
+
HELP_OPTIONS = %w[--help -h].freeze
|
|
17
|
+
OPTIONS_BY_COMMAND = {"format" => ["--check"], "fmt" => ["--check"]}.freeze
|
|
18
|
+
|
|
19
|
+
def call(arguments)
|
|
20
|
+
return puts(USAGE) || 0 if arguments.first == "help" || arguments.intersect?(HELP_OPTIONS)
|
|
21
|
+
|
|
22
|
+
command, *arguments = arguments
|
|
23
|
+
unknown_option = (arguments.grep(/\A-/) - OPTIONS_BY_COMMAND.fetch(command, [])).first
|
|
24
|
+
return warn("Unknown option #{unknown_option}", USAGE) || EX_USAGE if unknown_option
|
|
25
|
+
|
|
26
|
+
configuration = Linter.configuration
|
|
27
|
+
SuperSlim.tailwind_classes = configuration["tailwind_classes"] if configuration.key?("tailwind_classes")
|
|
28
|
+
case command
|
|
29
|
+
when "lint" then lint(arguments, configuration)
|
|
30
|
+
when "format", "fmt" then format(arguments, configuration["format"] || configuration.fetch("fmt", {}))
|
|
31
|
+
when "version", "--version" then puts(VERSION) || 0
|
|
32
|
+
else warn(USAGE) || EX_USAGE
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def lint(paths, configuration)
|
|
39
|
+
linter = Linter.new(configuration.fetch("linters", {}))
|
|
40
|
+
offenses = Parallel.map(templates(paths)) { |path| linter.call(path, File.read(path)) }.flatten
|
|
41
|
+
offenses.each { |offense| puts "#{offense.path}:#{offense.lineno}: [#{offense.rule}] #{offense.message}" }
|
|
42
|
+
offenses.empty? ? 0 : 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def format(arguments, options)
|
|
46
|
+
check = arguments.delete("--check")
|
|
47
|
+
formatter = Formatter.new(max_line_length: options["max_line_length"], attribute_delimiters: options.fetch("attribute_delimiters", "()"))
|
|
48
|
+
outcomes = Parallel.map(templates(arguments)) do |path|
|
|
49
|
+
source = File.read(path)
|
|
50
|
+
formatted = formatter.call(source)
|
|
51
|
+
next if formatted == source
|
|
52
|
+
|
|
53
|
+
File.write(path, formatted) unless check
|
|
54
|
+
[:reformatted, path]
|
|
55
|
+
rescue Error => exception
|
|
56
|
+
[:refused, "#{path}: #{exception.message.lines.first.strip}"]
|
|
57
|
+
end
|
|
58
|
+
outcomes.compact.each { |outcome, text| (outcome == :reformatted) ? puts(text) : warn(text) }
|
|
59
|
+
(outcomes.any? { |outcome,| outcome == :refused || (outcome == :reformatted && check) }) ? 1 : 0
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def templates(paths)
|
|
63
|
+
paths = ["."] if paths.empty?
|
|
64
|
+
paths.flat_map do |path|
|
|
65
|
+
next [path] unless File.directory?(path)
|
|
66
|
+
|
|
67
|
+
Dir.glob("**/*.slim", base: path).filter_map { |template| File.join(path, template) unless template.match?(SKIPPED_DIRECTORIES) }
|
|
68
|
+
end.sort
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SuperSlim
|
|
4
|
+
class Compiler
|
|
5
|
+
include AST
|
|
6
|
+
|
|
7
|
+
VOID_ELEMENTS = %w[area base br col embed hr img input keygen link menuitem meta param source track wbr].to_h { [_1, true] }.freeze
|
|
8
|
+
HYPHENATED_ATTRIBUTES = %w[data aria].freeze
|
|
9
|
+
DOCTYPES = {
|
|
10
|
+
html: {
|
|
11
|
+
"5" => "<!DOCTYPE html>",
|
|
12
|
+
"html" => "<!DOCTYPE html>",
|
|
13
|
+
"strict" => '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">',
|
|
14
|
+
"frameset" => '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Frameset//EN" "http://www.w3.org/TR/html4/frameset.dtd">',
|
|
15
|
+
"transitional" => '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">'
|
|
16
|
+
},
|
|
17
|
+
xml: {
|
|
18
|
+
"1.1" => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">',
|
|
19
|
+
"5" => "<!DOCTYPE html>",
|
|
20
|
+
"html" => "<!DOCTYPE html>",
|
|
21
|
+
"strict" => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">',
|
|
22
|
+
"frameset" => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Frameset//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-frameset.dtd">',
|
|
23
|
+
"mobile" => '<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN" "http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">',
|
|
24
|
+
"basic" => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML Basic 1.1//EN" "http://www.w3.org/TR/xhtml-basic/xhtml-basic11.dtd">',
|
|
25
|
+
"transitional" => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">',
|
|
26
|
+
"svg" => '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">'
|
|
27
|
+
}
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
BLOCK_SELF_OPENING = /(\A(if|unless|else|elsif|when|in|begin|rescue|ensure|case)\b)|\bdo\s*(\|[^|]*\|\s*)?\z/
|
|
31
|
+
BLOCK_OPENING = /\A(if|begin|unless|else|elsif|when|in|rescue|ensure)\b|\bdo\s*(\|[^|]*\|)?\s*$/
|
|
32
|
+
BLOCK_CONTINUATION = /\A(else|elsif|when|in|rescue|ensure)\b/
|
|
33
|
+
EXPLICIT_END = /\Aend\b/
|
|
34
|
+
CAPTURED_OUTPUT = /\A(if|unless)\b|\bdo\s*(\|[^|]*\|)?\s*$/
|
|
35
|
+
|
|
36
|
+
ESCAPED_INTERPOLATION = /\A\\#\{/
|
|
37
|
+
INTERPOLATION = /\A#\{((?>[^{}]|(\{(?>[^{}]|\g<1>)*\}))*)\}/
|
|
38
|
+
UNESCAPED_INTERPOLATION_CODE = /\A\{.*\}\Z/
|
|
39
|
+
INTERPOLATION_FREE_TEXT = /\A([#\\]?[^#\\]*([#\\][^\\#\{][^#\\]*)*)/ # rubocop:disable Style/RedundantRegexpEscape -- `#{` would open an interpolation
|
|
40
|
+
|
|
41
|
+
def initialize(format: :html, rails: false, html_safe: String.method_defined?(:html_safe?), preamble: nil, postamble: nil)
|
|
42
|
+
raise ArgumentError, "Invalid format #{format.inspect}" unless DOCTYPES.key?(format)
|
|
43
|
+
|
|
44
|
+
@xml = format == :xml
|
|
45
|
+
@doctypes = DOCTYPES.fetch(format)
|
|
46
|
+
@rails = rails
|
|
47
|
+
@html_safe = html_safe
|
|
48
|
+
@escape_function = html_safe ? "::SuperSlim::Runtime.escape_html_safe" : "::ERB::Escape.html_escape"
|
|
49
|
+
@preamble = preamble
|
|
50
|
+
@postamble = postamble
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def call(nodes)
|
|
54
|
+
@code = +""
|
|
55
|
+
@static = +""
|
|
56
|
+
@variable_count = 0
|
|
57
|
+
@buffer = @rails ? OutputBuffer.new : StringBuffer.new("_buf", @escape_function)
|
|
58
|
+
|
|
59
|
+
emit_code(@buffer.open)
|
|
60
|
+
emit_static(@preamble) if @preamble
|
|
61
|
+
compile_children(nodes)
|
|
62
|
+
emit_static(@postamble) if @postamble
|
|
63
|
+
emit_code(@buffer.close)
|
|
64
|
+
@code
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def compile_children(nodes)
|
|
70
|
+
block_open = false
|
|
71
|
+
nodes.each do |node|
|
|
72
|
+
if node.is_a?(Control)
|
|
73
|
+
raise Error, "Explicit end statements are forbidden" if node.code.match?(EXPLICIT_END)
|
|
74
|
+
|
|
75
|
+
emit_code("end") if block_open && !node.code.match?(BLOCK_CONTINUATION)
|
|
76
|
+
block_open = with_do(node.code, node.children).match?(BLOCK_OPENING)
|
|
77
|
+
elsif block_open && node != NEWLINE
|
|
78
|
+
emit_code("end")
|
|
79
|
+
block_open = false
|
|
80
|
+
end
|
|
81
|
+
compile_node(node)
|
|
82
|
+
end
|
|
83
|
+
emit_code("end") if block_open
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def compile_node(node)
|
|
87
|
+
case node
|
|
88
|
+
when NEWLINE
|
|
89
|
+
@code << "\n" unless @newline_emitted
|
|
90
|
+
@newline_emitted = false
|
|
91
|
+
when Static then emit_static(node.text)
|
|
92
|
+
when Text then emit_interpolated(node.text)
|
|
93
|
+
when TextBlock then compile_children(node.children)
|
|
94
|
+
when Tag then compile_tag(node)
|
|
95
|
+
when Control then compile_control(node)
|
|
96
|
+
when Output then compile_output(node)
|
|
97
|
+
when InlineHtml
|
|
98
|
+
emit_interpolated(node.text)
|
|
99
|
+
compile_children(node.children)
|
|
100
|
+
when Comment
|
|
101
|
+
emit_static("<!--")
|
|
102
|
+
compile_children(node.children)
|
|
103
|
+
emit_static("-->")
|
|
104
|
+
when Doctype then emit_static(doctype(node.type))
|
|
105
|
+
when Embedded then compile_embedded(node)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def compile_control(control)
|
|
110
|
+
emit_code(with_do(control.code, control.children))
|
|
111
|
+
compile_children(control.children)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def compile_output(output)
|
|
115
|
+
code = with_do(output.code, output.children)
|
|
116
|
+
unless code.match?(CAPTURED_OUTPUT)
|
|
117
|
+
emit_output(code, output.escape)
|
|
118
|
+
return compile_children(output.children)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
result = new_variable
|
|
122
|
+
emit_code("#{result} = #{code}")
|
|
123
|
+
if @rails
|
|
124
|
+
compile_children(output.children)
|
|
125
|
+
else
|
|
126
|
+
capture_into(new_variable) { compile_children(output.children) }
|
|
127
|
+
end
|
|
128
|
+
emit_code("end")
|
|
129
|
+
emit_output(result, output.escape)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def with_do(code, children)
|
|
133
|
+
(code.match?(BLOCK_SELF_OPENING) || AST.empty?(children)) ? code : "#{code} do"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def compile_tag(tag)
|
|
137
|
+
return compile_dynamic_tag(tag) if tag.name == "*"
|
|
138
|
+
|
|
139
|
+
closed = tag.content.nil? || (AST.renders_nothing?(tag.content) && (@xml || VOID_ELEMENTS.key?(tag.name)))
|
|
140
|
+
@static << "<" << tag.name
|
|
141
|
+
compile_attributes(tag.attributes)
|
|
142
|
+
emit_static((closed && @xml) ? " />" : ">")
|
|
143
|
+
return if closed && tag.content.nil?
|
|
144
|
+
|
|
145
|
+
compile_children(tag.content)
|
|
146
|
+
@static << "</" << tag.name << ">" unless closed
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def compile_dynamic_tag(tag)
|
|
150
|
+
attributes = compile_runtime_attributes(tag.attributes)
|
|
151
|
+
if tag.content.nil?
|
|
152
|
+
emit_output("#{attributes}.build_tag", false)
|
|
153
|
+
elsif AST.empty?(tag.content)
|
|
154
|
+
emit_output(%(#{attributes}.build_tag("")), false)
|
|
155
|
+
compile_children(tag.content)
|
|
156
|
+
else
|
|
157
|
+
content = new_variable
|
|
158
|
+
capture_into(content) { compile_children(tag.content) }
|
|
159
|
+
emit_output("#{attributes}.build_tag(#{content})", false)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def compile_attributes(attributes)
|
|
164
|
+
return if attributes.empty?
|
|
165
|
+
return emit_output(compile_runtime_attributes(attributes), false) if attributes.any? { runtime_attribute?(_1) }
|
|
166
|
+
|
|
167
|
+
if attributes.size > 1
|
|
168
|
+
seen_names = {}
|
|
169
|
+
attributes.each do |attribute|
|
|
170
|
+
next if attribute.name == "class"
|
|
171
|
+
raise Error, "Multiple #{attribute.name} attributes specified" if seen_names.key?(attribute.name)
|
|
172
|
+
|
|
173
|
+
seen_names[attribute.name] = true
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Every class value renders at the place of the first class attribute.
|
|
178
|
+
class_attributes = nil
|
|
179
|
+
attributes.each do |attribute|
|
|
180
|
+
next compile_attribute(attribute) unless attribute.name == "class"
|
|
181
|
+
next if class_attributes
|
|
182
|
+
|
|
183
|
+
class_attributes = attributes.select { _1.name == "class" }
|
|
184
|
+
compile_class_attribute(class_attributes.map { attribute_parts(_1) })
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def runtime_attribute?(attribute)
|
|
189
|
+
attribute.is_a?(Splat) || (attribute.is_a?(CodeAttribute) && HYPHENATED_ATTRIBUTES.include?(attribute.name))
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def compile_attribute(attribute)
|
|
193
|
+
case attribute
|
|
194
|
+
when BooleanAttribute then emit_boolean_attribute(attribute.name)
|
|
195
|
+
when CodeAttribute then compile_code_attribute(attribute)
|
|
196
|
+
else emit_attribute(attribute.name, attribute_parts(attribute))
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def compile_code_attribute(attribute)
|
|
201
|
+
case attribute.code
|
|
202
|
+
when "true" then emit_boolean_attribute(attribute.name)
|
|
203
|
+
when "false", "nil" then nil
|
|
204
|
+
else
|
|
205
|
+
value = new_variable
|
|
206
|
+
# A case on literals compiles to a jump table: no `==` call for the strings and numbers most values are.
|
|
207
|
+
emit_code("case (#{value} = #{attribute.code})")
|
|
208
|
+
emit_code("when true")
|
|
209
|
+
emit_boolean_attribute(attribute.name)
|
|
210
|
+
emit_code("when false, nil")
|
|
211
|
+
emit_code("else")
|
|
212
|
+
emit_attribute(attribute.name, [[:dynamic, value, attribute.escape]])
|
|
213
|
+
emit_code("end")
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Slim joins the non-empty values with spaces and drops an empty result. When the first value carries literal
|
|
218
|
+
# text, as shortcuts always come first, the attribute is certain to render: each later value only needs its
|
|
219
|
+
# separator, so nothing is joined at render time.
|
|
220
|
+
def compile_class_attribute(values)
|
|
221
|
+
values = values.reject { |parts| parts.all? { |kind, value| kind == :static && value.empty? } }
|
|
222
|
+
if values.empty?
|
|
223
|
+
nil
|
|
224
|
+
elsif literal_text?(values.first)
|
|
225
|
+
emit_static(' class="')
|
|
226
|
+
values.each_with_index { |parts, index| emit_class_value(parts, separator: index > 0) }
|
|
227
|
+
emit_static('"')
|
|
228
|
+
else
|
|
229
|
+
classes = new_variable
|
|
230
|
+
expressions = values.map { string_expression(_1) }
|
|
231
|
+
emit_code((values.size == 1) ? "#{classes} = #{expressions.first}" : "#{classes} = ::SuperSlim::Runtime.join_classes(#{expressions.join(", ")})")
|
|
232
|
+
emit_code("unless #{classes}.empty?")
|
|
233
|
+
emit_attribute("class", [[:raw, classes]])
|
|
234
|
+
emit_code("end")
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def emit_class_value(parts, separator:)
|
|
239
|
+
if literal_text?(parts)
|
|
240
|
+
emit_static(" ") if separator
|
|
241
|
+
emit_parts(parts)
|
|
242
|
+
else
|
|
243
|
+
value = new_variable
|
|
244
|
+
emit_code("#{value} = #{string_expression(parts)}")
|
|
245
|
+
emit_code("unless #{value}.empty?")
|
|
246
|
+
emit_static(" ") if separator
|
|
247
|
+
emit_output(value, false)
|
|
248
|
+
emit_code("end")
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def literal_text?(parts)
|
|
253
|
+
parts.any? { |kind, value| kind == :static && !value.empty? }
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def compile_runtime_attributes(attributes)
|
|
257
|
+
builder = new_variable
|
|
258
|
+
emit_code("#{builder} = ::SuperSlim::Runtime::Attributes.new(#{@html_safe})")
|
|
259
|
+
attributes.each do |attribute|
|
|
260
|
+
case attribute
|
|
261
|
+
when Splat
|
|
262
|
+
emit_code("#{builder}.splat_attrs((#{attribute.code}))")
|
|
263
|
+
when CodeAttribute
|
|
264
|
+
emit_code("#{builder}.code_attr(#{attribute.name.inspect}, #{attribute.escape}, (#{attribute.code}))")
|
|
265
|
+
else
|
|
266
|
+
emit_code("#{builder}.attr(#{attribute.name.inspect}, #{string_expression(attribute_parts(attribute))})")
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
builder
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def attribute_parts(attribute)
|
|
273
|
+
case attribute
|
|
274
|
+
when StaticAttribute then [[:static, attribute.value]]
|
|
275
|
+
when BooleanAttribute then []
|
|
276
|
+
when CodeAttribute
|
|
277
|
+
value = new_variable
|
|
278
|
+
emit_code("#{value} = #{attribute.code}")
|
|
279
|
+
[[:raw, "::SuperSlim::Runtime.class_value(#{value}, #{attribute.escape}, #{@html_safe})"]]
|
|
280
|
+
when QuotedAttribute
|
|
281
|
+
interpolation_parts(attribute.text).map do |kind, value, escape|
|
|
282
|
+
(kind == :static && attribute.escape) ? [:static, ERB::Escape.html_escape(value)] : [kind, value, escape]
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def emit_attribute(name, parts)
|
|
288
|
+
emit_static(%( #{name}="))
|
|
289
|
+
emit_parts(parts)
|
|
290
|
+
emit_static('"')
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def emit_boolean_attribute(name)
|
|
294
|
+
emit_static(@xml ? %( #{name}="") : " #{name}")
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def emit_parts(parts)
|
|
298
|
+
parts.each do |kind, value, escape|
|
|
299
|
+
case kind
|
|
300
|
+
when :static then emit_static(value)
|
|
301
|
+
when :dynamic then emit_output(value, escape)
|
|
302
|
+
when :raw then emit_output(value, false)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def string_expression(parts)
|
|
308
|
+
if parts.size == 1
|
|
309
|
+
kind, value, escape = parts.first
|
|
310
|
+
return "#{@escape_function}((#{value}))" if kind == :dynamic && escape
|
|
311
|
+
return value if kind == :raw
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
literal = parts.map do |kind, value, escape|
|
|
315
|
+
case kind
|
|
316
|
+
when :static then value.inspect[1..-2]
|
|
317
|
+
when :dynamic then escape ? "\#{#{@escape_function}((#{value}))}" : "\#{#{value}}"
|
|
318
|
+
when :raw then "\#{#{value}}"
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
%("#{literal.join}")
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def emit_interpolated(text)
|
|
325
|
+
emit_parts(interpolation_parts(text))
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def interpolation_parts(text)
|
|
329
|
+
return [[:static, text]] unless text.include?('#{')
|
|
330
|
+
|
|
331
|
+
parts = []
|
|
332
|
+
loop do
|
|
333
|
+
case text
|
|
334
|
+
when ESCAPED_INTERPOLATION
|
|
335
|
+
parts << [:static, '#{']
|
|
336
|
+
when INTERPOLATION
|
|
337
|
+
code = $1
|
|
338
|
+
escape = !code.match?(UNESCAPED_INTERPOLATION_CODE)
|
|
339
|
+
parts << [:dynamic, escape ? code : code[1..-2], escape]
|
|
340
|
+
when INTERPOLATION_FREE_TEXT
|
|
341
|
+
parts << [:static, $&]
|
|
342
|
+
end
|
|
343
|
+
text = $'
|
|
344
|
+
break if text.empty?
|
|
345
|
+
end
|
|
346
|
+
parts
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def compile_embedded(embedded)
|
|
350
|
+
case embedded.engine
|
|
351
|
+
when "ruby"
|
|
352
|
+
compile_ruby(embedded.children)
|
|
353
|
+
when "javascript" then compile_tag(Tag.new("script", embedded.attributes, [TextBlock.new(embedded.children)]))
|
|
354
|
+
when "css" then compile_tag(Tag.new("style", embedded.attributes, embedded.children))
|
|
355
|
+
when "markdown" then compile_markdown(embedded.children)
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Keeps every line of the block on its template line, blank lines included. The code must end with a
|
|
360
|
+
# newline, as its last line may be a comment: when no blank line follows, that newline is the one of the
|
|
361
|
+
# newline node that comes next.
|
|
362
|
+
def compile_ruby(children)
|
|
363
|
+
code = children.grep(Text).map(&:text).join
|
|
364
|
+
trailing_newline_count = children.count(NEWLINE) - 1 - code.count("\n")
|
|
365
|
+
@code << "\n"
|
|
366
|
+
emit_code(code)
|
|
367
|
+
@code << ("\n" * [trailing_newline_count, 1].max)
|
|
368
|
+
@newline_emitted = trailing_newline_count.zero?
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def compile_markdown(children)
|
|
372
|
+
require "markly"
|
|
373
|
+
|
|
374
|
+
placeholder = "%#{object_id.abs.to_s(36)}%"
|
|
375
|
+
dynamic_parts = []
|
|
376
|
+
markdown = children.grep(Text).flat_map { interpolation_parts(_1.text) }.map do |part|
|
|
377
|
+
next part[1] if part.first == :static
|
|
378
|
+
|
|
379
|
+
dynamic_parts << part
|
|
380
|
+
"#{placeholder}#{dynamic_parts.size - 1}%"
|
|
381
|
+
end
|
|
382
|
+
Markly.render_html(markdown.join).split(/#{placeholder}(\d+)%/).each_with_index do |piece, index|
|
|
383
|
+
index.odd? ? emit_parts([dynamic_parts[piece.to_i]]) : emit_static(piece)
|
|
384
|
+
end
|
|
385
|
+
children.count(NEWLINE).times { @code << "\n" }
|
|
386
|
+
rescue LoadError
|
|
387
|
+
raise Error, "Embedded engine markdown requires the markly gem"
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def doctype(type)
|
|
391
|
+
type = type.downcase
|
|
392
|
+
if (xml = type.match(/\Axml(\s+(.+))?\z/))
|
|
393
|
+
raise Error, "Invalid xml directive in html mode" unless @xml
|
|
394
|
+
|
|
395
|
+
%(<?xml version="1.0" encoding="#{xml[2] || "utf-8"}" ?>)
|
|
396
|
+
else
|
|
397
|
+
@doctypes[type] || raise(Error, "Invalid doctype #{type}")
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def capture_into(variable)
|
|
402
|
+
if @rails
|
|
403
|
+
emit_code("#{variable} = capture do")
|
|
404
|
+
yield
|
|
405
|
+
emit_code("end")
|
|
406
|
+
else
|
|
407
|
+
outer_buffer = @buffer
|
|
408
|
+
flush_static
|
|
409
|
+
@buffer = StringBuffer.new(variable, @escape_function)
|
|
410
|
+
emit_code(@buffer.open)
|
|
411
|
+
yield
|
|
412
|
+
emit_code(@buffer.close)
|
|
413
|
+
@buffer = outer_buffer
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def emit_static(text)
|
|
418
|
+
@static << text
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def emit_output(code, escape)
|
|
422
|
+
emit_code(escape ? @buffer.escaped(code) : @buffer.raw(code))
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def emit_code(code)
|
|
426
|
+
flush_static
|
|
427
|
+
append(code)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def flush_static
|
|
431
|
+
return if @static.empty?
|
|
432
|
+
|
|
433
|
+
append(@buffer.static(@static))
|
|
434
|
+
@static = +""
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def append(code)
|
|
438
|
+
@code << "; " unless @code.empty? || @code.end_with?("\n")
|
|
439
|
+
@code << code
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def new_variable
|
|
443
|
+
"_super_slim#{@variable_count += 1}"
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
class StringBuffer
|
|
447
|
+
def initialize(name, escape_function)
|
|
448
|
+
@name = name
|
|
449
|
+
@escape_function = escape_function
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def open = "#{@name} = +\"\""
|
|
453
|
+
def close = @name
|
|
454
|
+
def static(text) = "#{@name} << #{text.inspect}.freeze"
|
|
455
|
+
def escaped(code) = "#{@name} << #{@escape_function}((#{code}))"
|
|
456
|
+
def raw(code) = "#{@name} << (#{code}).to_s"
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
class OutputBuffer
|
|
460
|
+
def open = "@output_buffer = output_buffer || ActionView::OutputBuffer.new"
|
|
461
|
+
def close = "@output_buffer"
|
|
462
|
+
def static(text) = "@output_buffer.safe_concat(#{text.inspect}.freeze)"
|
|
463
|
+
def escaped(code) = "@output_buffer << (#{code})"
|
|
464
|
+
def raw(code) = "@output_buffer.safe_concat((#{code}).to_s)"
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ripper"
|
|
4
|
+
|
|
5
|
+
module SuperSlim
|
|
6
|
+
class Formatter
|
|
7
|
+
# Breaks the lines of a formatted template longer than max_line_length: a tag's attributes one per line
|
|
8
|
+
# inside attribute_delimiters, then the arguments of its Ruby code after each comma, as Slim reads a line ending
|
|
9
|
+
# with a comma as continued on the next one. Lines already spread over several lines are left as they are.
|
|
10
|
+
class LineWrapper
|
|
11
|
+
CODE_LINE = /\A *(?:-|==?[<>]*) /
|
|
12
|
+
TAG_OUTPUT = /\G\s*==?['<>]*\s*/
|
|
13
|
+
OPENINGS = %i[on_lparen on_lbracket on_lbrace on_tlambeg on_embexpr_beg].freeze
|
|
14
|
+
CLOSINGS = %i[on_rparen on_rbracket on_rbrace on_embexpr_end].freeze
|
|
15
|
+
CONTINUATION_KINDS = %i[continuation text text_blank].freeze
|
|
16
|
+
MODIFIERS = %w[if unless while until rescue].freeze
|
|
17
|
+
|
|
18
|
+
# Templates attach ( and [ to their tag but set { apart, as a Ruby block: 6,097 public templates agree.
|
|
19
|
+
OPENING_BY_ATTRIBUTE_DELIMITERS = {"()" => "(", "[]" => "[", "{}" => " {"}.freeze
|
|
20
|
+
|
|
21
|
+
def initialize(max_line_length, tailwind_classes:, attribute_delimiters: "()")
|
|
22
|
+
@opening = OPENING_BY_ATTRIBUTE_DELIMITERS.fetch(attribute_delimiters) do
|
|
23
|
+
raise ArgumentError, "Invalid attribute_delimiters #{attribute_delimiters.inspect}: one of #{OPENING_BY_ATTRIBUTE_DELIMITERS.keys.join(", ")}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
@max_line_length = max_line_length
|
|
27
|
+
@tailwind_classes = tailwind_classes
|
|
28
|
+
@closing = attribute_delimiters[1]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call(source)
|
|
32
|
+
lines = source.split("\n")
|
|
33
|
+
return source if lines.none? { _1.size > @max_line_length }
|
|
34
|
+
|
|
35
|
+
parser = Parser.new(outline: true, tailwind_classes: @tailwind_classes)
|
|
36
|
+
parser.call(source)
|
|
37
|
+
wrapped = lines.each.with_index(1).flat_map do |line, lineno|
|
|
38
|
+
info = parser.outline[lineno]
|
|
39
|
+
next [line] unless line.size > @max_line_length && info.kind == :structure && one_line?(parser.outline[lineno + 1], lineno)
|
|
40
|
+
|
|
41
|
+
wrap(line, info, children?(parser.outline, lineno))
|
|
42
|
+
end
|
|
43
|
+
"#{wrapped.join("\n")}\n"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def one_line?(next_info, lineno)
|
|
49
|
+
next_info.nil? || next_info.owner != lineno || !CONTINUATION_KINDS.include?(next_info.kind)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def children?(outline, lineno)
|
|
53
|
+
next_structure = outline[(lineno + 1)..].find { _1.kind == :structure }
|
|
54
|
+
next_structure && next_structure.depth > outline[lineno].depth
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def wrap(line, info, children)
|
|
58
|
+
indent = line[/\A */]
|
|
59
|
+
lists = info.attribute_lists
|
|
60
|
+
lines, code_start =
|
|
61
|
+
if lists&.size == 1 && named_tag?(line[0...lists.first.first].strip)
|
|
62
|
+
wrap_attributes(line, lists.first, indent, info.output_column)
|
|
63
|
+
else
|
|
64
|
+
[[line], code_start(line, info.output_column)]
|
|
65
|
+
end
|
|
66
|
+
return lines unless code_start && lines.last.size > @max_line_length
|
|
67
|
+
|
|
68
|
+
# Arguments go two levels deeper than the line when it opens a block, so they stand apart from its children.
|
|
69
|
+
argument_indent = indent + (" " * (children ? 2 * INDENT_WIDTH : INDENT_WIDTH))
|
|
70
|
+
lines[0...-1] + wrap_arguments(lines.last, code_start, argument_indent)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A dynamic tag, `*{tag: name}` or `li: *{tag: name}`, has no name for its attribute list to follow.
|
|
74
|
+
def named_tag?(head)
|
|
75
|
+
!head.empty? && !head.end_with?(":")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns the lines and where the Ruby code of the last one starts, if it outputs some.
|
|
79
|
+
def wrap_attributes(line, (first, last, spans), indent, output_column)
|
|
80
|
+
attribute_indent = indent + (" " * INDENT_WIDTH)
|
|
81
|
+
closing_line = indent + @closing + line[last..]
|
|
82
|
+
lines = [line[0...first].rstrip + @opening, *spans.map { |span_first, span_last| attribute_indent + line[span_first...span_last] }, closing_line]
|
|
83
|
+
[lines, output_column && code_start(closing_line, output_column - last + indent.size + @closing.size)]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def code_start(line, output_column)
|
|
87
|
+
if output_column
|
|
88
|
+
TAG_OUTPUT.match(line, output_column)&.end(0)
|
|
89
|
+
elsif (match = CODE_LINE.match(line))
|
|
90
|
+
match.end(0)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def wrap_arguments(line, code_start, argument_indent)
|
|
95
|
+
code = line[code_start..]
|
|
96
|
+
offsets = argument_breaks(code)
|
|
97
|
+
return [line] if offsets.empty?
|
|
98
|
+
|
|
99
|
+
first = 0
|
|
100
|
+
first_piece, *pieces = [*offsets, code.bytesize].map do |last|
|
|
101
|
+
piece = code.byteslice(first...last).strip
|
|
102
|
+
first = last
|
|
103
|
+
piece
|
|
104
|
+
end
|
|
105
|
+
[line[0...code_start] + first_piece, *pieces.map { argument_indent + _1 }]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Byte offsets right after each comma that separates the arguments of the outermost call, up to its block.
|
|
109
|
+
def argument_breaks(code)
|
|
110
|
+
depth = 0
|
|
111
|
+
offsets = []
|
|
112
|
+
Ripper.lex(code).each do |(_, column), type, token|
|
|
113
|
+
case type
|
|
114
|
+
when :on_heredoc_beg then return []
|
|
115
|
+
when :on_kw
|
|
116
|
+
break if token == "do"
|
|
117
|
+
# RuboCop refuses a modifier that ends a statement spread over several lines (Style/MultilineIfModifier).
|
|
118
|
+
return [] if depth.zero? && MODIFIERS.include?(token)
|
|
119
|
+
when :on_op
|
|
120
|
+
# `a, b = …` assigns several variables: its commas separate no arguments.
|
|
121
|
+
return [] if depth.zero? && token == "=" && !offsets.empty?
|
|
122
|
+
when :on_comma then offsets << column + 1 if depth.zero?
|
|
123
|
+
when *OPENINGS then depth += 1
|
|
124
|
+
when *CLOSINGS then depth -= 1
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
offsets
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|