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 +7 -0
- data/LICENSE +46 -0
- data/exe/slimx +6 -0
- data/lib/slimx/ast.rb +34 -0
- data/lib/slimx/cli.rb +63 -0
- data/lib/slimx/compiler.rb +467 -0
- data/lib/slimx/formatter.rb +99 -0
- data/lib/slimx/linter/rules.rb +446 -0
- data/lib/slimx/linter.rb +78 -0
- data/lib/slimx/parallel.rb +36 -0
- data/lib/slimx/parser.rb +498 -0
- data/lib/slimx/rails_handler.rb +31 -0
- data/lib/slimx/railtie.rb +12 -0
- data/lib/slimx/runtime.rb +98 -0
- data/lib/slimx/shortcuts.rb +62 -0
- data/lib/slimx/tilt.rb +18 -0
- data/lib/slimx/version.rb +5 -0
- data/lib/slimx.rb +68 -0
- metadata +60 -0
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
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
|