slimx 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d6e59db7648504bb1fb7d72edab85cfa9a447ac77e8d57d9eb4f688e117a5257
4
+ data.tar.gz: '02819d5cae3d44e0d1f038ff28ea4fccd59ef5662c63a24850f63de65cfc1614'
5
+ SHA512:
6
+ metadata.gz: 565fc43d40adaba83d89388020396a9472113c21dfeac9170bce50043a538dbda4eddb3ed730c4bca731e14a38fd8be73784d395025439f4cb6e626b964e6fcc
7
+ data.tar.gz: 693b95760aa247a9fa927b0bd126fccced25d46aae91bfb517e80f46a194df7b93aa9038394463f47c726740f19366eb9eee12b9e0715a85be30901d61bcf7b9
data/LICENSE ADDED
@@ -0,0 +1,46 @@
1
+ The MIT License
2
+
3
+ Copyright (c) 2026 Clément Joubert
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
13
+ all 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
21
+ THE SOFTWARE.
22
+
23
+ ---
24
+
25
+ SlimX's parser follows the grammar and the parsing algorithm of Slim
26
+ (https://github.com/slim-template/slim), released under the MIT License:
27
+
28
+ Copyright (c) 2010 - 2026 Slim Team
29
+
30
+ Permission is hereby granted, free of charge, to any person obtaining a copy
31
+ of this software and associated documentation files (the "Software"), to deal
32
+ in the Software without restriction, including without limitation the rights
33
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
34
+ copies of the Software, and to permit persons to whom the Software is
35
+ furnished to do so, subject to the following conditions:
36
+
37
+ The above copyright notice and this permission notice shall be included in
38
+ all copies or substantial portions of the Software.
39
+
40
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
41
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
42
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
43
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
44
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
45
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
46
+ THE SOFTWARE.
data/exe/slimx ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/slimx/cli"
5
+
6
+ exit SlimX::CLI.new.call(ARGV)
data/lib/slimx/ast.rb ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlimX
4
+ module AST
5
+ NEWLINE = :newline
6
+
7
+ # Structs, not Data: Data.new allocates two more objects per node and is 2.8x slower, on every template line.
8
+ Static = Struct.new(:text)
9
+ Text = Struct.new(:text)
10
+ TextBlock = Struct.new(:children)
11
+ InlineHtml = Struct.new(:text, :children)
12
+ Tag = Struct.new(:name, :attributes, :content)
13
+ Control = Struct.new(:code, :children)
14
+ Output = Struct.new(:code, :escape, :children)
15
+ Comment = Struct.new(:children)
16
+ Doctype = Struct.new(:type)
17
+ Embedded = Struct.new(:engine, :children, :attributes)
18
+
19
+ StaticAttribute = Struct.new(:name, :value)
20
+ QuotedAttribute = Struct.new(:name, :escape, :text)
21
+ CodeAttribute = Struct.new(:name, :escape, :code)
22
+ BooleanAttribute = Struct.new(:name)
23
+ Splat = Struct.new(:code)
24
+
25
+ def self.empty?(nodes)
26
+ nodes.all? { |node| node == NEWLINE }
27
+ end
28
+
29
+ # Once compiled, a text block with no text leaves nothing behind: Slim then closes a void tag holding one.
30
+ def self.renders_nothing?(nodes)
31
+ nodes.all? { |node| node == NEWLINE || (node.is_a?(TextBlock) && renders_nothing?(node.children)) }
32
+ end
33
+ end
34
+ end
data/lib/slimx/cli.rb ADDED
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../slimx"
4
+ require_relative "linter"
5
+ require_relative "parallel"
6
+
7
+ module SlimX
8
+ class CLI
9
+ USAGE = <<~TEXT
10
+ Usage: slimx lint [PATH…] report what breaks the rules of .slimx.yml
11
+ slimx fmt [--check] [PATH…] rewrite templates in their canonical layout
12
+ slimx version
13
+ TEXT
14
+ SKIPPED_DIRECTORIES = %r{(\A|/)(\.git|node_modules|tmp|vendor)/}
15
+ EX_USAGE = 64
16
+
17
+ def call(arguments)
18
+ command, *arguments = arguments
19
+ configuration = Linter.configuration
20
+ SlimX.tailwind_classes = configuration["tailwind_classes"] if configuration.key?("tailwind_classes")
21
+ case command
22
+ when "lint" then lint(arguments, configuration)
23
+ when "fmt" then format(arguments)
24
+ when "version", "--version" then puts(VERSION) || 0
25
+ else warn(USAGE) || EX_USAGE
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def lint(paths, configuration)
32
+ linter = Linter.new(configuration.fetch("linters", {}))
33
+ offenses = Parallel.map(templates(paths)) { linter.call(it, File.read(it)) }.flatten
34
+ offenses.each { puts "#{it.path}:#{it.lineno}: [#{it.rule}] #{it.message}" }
35
+ offenses.empty? ? 0 : 1
36
+ end
37
+
38
+ def format(arguments)
39
+ check = arguments.delete("--check")
40
+ outcomes = Parallel.map(templates(arguments)) do |path|
41
+ source = File.read(path)
42
+ formatted = Formatter.call(source)
43
+ next if formatted == source
44
+
45
+ File.write(path, formatted) unless check
46
+ [:reformatted, path]
47
+ rescue Error => exception
48
+ [:refused, "#{path}: #{exception.message.lines.first.strip}"]
49
+ end
50
+ outcomes.compact.each { |outcome, text| outcome == :reformatted ? puts(text) : warn(text) }
51
+ outcomes.any? { |outcome,| outcome == :refused || (outcome == :reformatted && check) } ? 1 : 0
52
+ end
53
+
54
+ def templates(paths)
55
+ paths = ["."] if paths.empty?
56
+ paths.flat_map do |path|
57
+ next [path] unless File.directory?(path)
58
+
59
+ Dir.glob("**/*.slim", base: path).reject { it.match?(SKIPPED_DIRECTORIES) }.map { File.join(path, it) }
60
+ end.sort
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,467 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlimX
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 { [it, 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([#\\]?[^#\\]*([#\\][^\\#\{][^#\\]*)*)/
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
+ @format = format
46
+ @rails = rails
47
+ @html_safe = html_safe
48
+ @escape_function = html_safe ? "::SlimX::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
+ emit_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
+ emit_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?(it) }
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 { it.name == "class" }
184
+ compile_class_attribute(class_attributes.map { attribute_parts(it) })
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(it) }
231
+ emit_code(values.size == 1 ? "#{classes} = #{expressions.first}" : "#{classes} = ::SlimX::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} = ::SlimX::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, "::SlimX::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(it.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.fetch(@format)[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
+ "_slimx#{@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