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
data/lib/slimx/parser.rb
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
|
|
5
|
+
module SlimX
|
|
6
|
+
# Reads each line with one StringScanner that moves forward: no MatchData, and no copy of the rest of the
|
|
7
|
+
# line after each token, so parsing allocates little more than the nodes it builds.
|
|
8
|
+
class Parser
|
|
9
|
+
include AST
|
|
10
|
+
|
|
11
|
+
TAB_SIZE = 4
|
|
12
|
+
TAB = /\G((?: {#{TAB_SIZE}})*) {0,#{TAB_SIZE - 1}}\t/
|
|
13
|
+
DELIMITERS = { "(" => ")", "[" => "]", "{" => "}" }.freeze
|
|
14
|
+
SUPPORTED_ENGINES = %w[javascript css ruby markdown].freeze
|
|
15
|
+
# Engines Slim ships that SlimX deliberately refuses, so they fail loudly instead of parsing as a tag.
|
|
16
|
+
UNSUPPORTED_ENGINES = %w[asciidoc textile rdoc coffee sass scss less erb builder creole wiki org].freeze
|
|
17
|
+
ENGINE = /(#{Regexp.union(SUPPORTED_ENGINES + UNSUPPORTED_ENGINES)})(?:\s*(?:(.*)))?:(\s*)/
|
|
18
|
+
DOCTYPE = /doctype\b/
|
|
19
|
+
|
|
20
|
+
ATTRIBUTE_NAME = /\s*([^\0\s"'><\/=()\[\]{}]+)/
|
|
21
|
+
QUOTED_ATTRIBUTE = /#{ATTRIBUTE_NAME}\s*=(=?)\s*("|')/
|
|
22
|
+
CODE_ATTRIBUTE = /#{ATTRIBUTE_NAME}\s*=(=?)\s*/
|
|
23
|
+
SPLAT = /\s*\*(?=[^\s]+)/
|
|
24
|
+
ATTRIBUTE_LIST_OPENING = /\s*([(\[{])/
|
|
25
|
+
BLANK_LINE = /\A\s*\z/
|
|
26
|
+
BLANK_REST = /\s*\z/
|
|
27
|
+
WHITESPACE = /\s*/
|
|
28
|
+
SPACE_BYTE = " ".ord
|
|
29
|
+
TAB_BYTE = "\t".ord
|
|
30
|
+
# At the scanner, the rest of the line is only this character; on a whole line, the line ends with it.
|
|
31
|
+
LINE_CONTINUATION = /[,\\]\z/
|
|
32
|
+
BACKSLASH_AT_END = /\\\z/
|
|
33
|
+
WHITESPACE_MARKERS = /[<>']*/
|
|
34
|
+
TEXT_LINE_INDICATOR = /([|'])([<>]{1,2}(?: |\z)| ?)/
|
|
35
|
+
HTML_COMMENT = /\/!( ?)/
|
|
36
|
+
CONDITIONAL_COMMENT = /\/\[\s*(.*?)\s*\]\s*\z/
|
|
37
|
+
NESTED_TAG = /\s*:\s*/
|
|
38
|
+
TAG_OUTPUT = /\s*=(=?)(['<>]*)/
|
|
39
|
+
CLOSED_TAG = /\s*\/\s*/
|
|
40
|
+
SHORTCUT_VALUE_TO_ESCAPE = /[&<>"']/
|
|
41
|
+
# Runs of characters the scanners would otherwise copy one by one: no delimiter, no line continuation.
|
|
42
|
+
CODE_CHUNK_BY_CLOSING = [nil, *DELIMITERS.values].to_h { [it, /(?:[^\s(\[{,\\#{Regexp.escape(it.to_s)}]|[,\\](?!\z))+/] }.freeze
|
|
43
|
+
NESTED_CODE_CHUNK_BY_OPENING = DELIMITERS.to_h { |opening, closing| [opening, /(?:[^,\\#{Regexp.escape(opening + closing)}]|[,\\](?!\z))+/] }.freeze
|
|
44
|
+
QUOTED_CHUNK_BY_QUOTE = %w[" '].to_h { [it, /(?:[^{}\\#{it}]|\\(?!\z))+/] }.freeze
|
|
45
|
+
SLASH, PIPE, APOSTROPHE, LESS_THAN, GREATER_THAN, DASH, EQUALS = "/|'<>-=".bytes
|
|
46
|
+
OPENING_BRACE, CLOSING_BRACE = "{}".bytes
|
|
47
|
+
WHITESPACE_MARKER_BYTES = "<>'".bytes.freeze
|
|
48
|
+
BOOLEAN_ATTRIBUTE_BY_CLOSING = DELIMITERS.values.to_h { [it, /#{ATTRIBUTE_NAME}(?=(\s|#{Regexp.escape(it)}|\z))/] }.freeze
|
|
49
|
+
ATTRIBUTE_LIST_END_BY_CLOSING = DELIMITERS.values.to_h { [it, /\s*#{Regexp.escape(it)}/] }.freeze
|
|
50
|
+
CODE_END_BY_CLOSING = [nil, *DELIMITERS.values].to_h { [it, /[\s#{Regexp.escape(it.to_s)}]/] }.freeze
|
|
51
|
+
|
|
52
|
+
# What the formatter and the linter need to know about each source line, indexed by line number.
|
|
53
|
+
LineInfo = Struct.new(:kind, :depth, :owner, :text, :output_column)
|
|
54
|
+
|
|
55
|
+
attr_reader :outline, :line_by_node
|
|
56
|
+
|
|
57
|
+
def initialize(shortcuts: Shortcuts::DEFAULT, file: nil, outline: false, tailwind_classes: SlimX.tailwind_classes)
|
|
58
|
+
@file = file
|
|
59
|
+
@shortcuts = Shortcuts.for(shortcuts, tailwind_classes: !!tailwind_classes)
|
|
60
|
+
@tag = @shortcuts.tag_pattern
|
|
61
|
+
@record_outline = outline
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def call(source)
|
|
65
|
+
root = []
|
|
66
|
+
if @record_outline
|
|
67
|
+
@outline = [nil]
|
|
68
|
+
@line_by_node = {}.compare_by_identity
|
|
69
|
+
end
|
|
70
|
+
@lines = normalize(source).split(/\r?\n/)
|
|
71
|
+
@scanner = StringScanner.new("")
|
|
72
|
+
@stacks = [root]
|
|
73
|
+
@indents = []
|
|
74
|
+
@lineno = 0
|
|
75
|
+
parse_line while next_line
|
|
76
|
+
root
|
|
77
|
+
ensure
|
|
78
|
+
@lines = @stacks = @line = @scanner = nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def normalize(source)
|
|
84
|
+
source = +source
|
|
85
|
+
unless source.encoding == Encoding::UTF_8
|
|
86
|
+
original_encoding = source.encoding
|
|
87
|
+
source.force_encoding(Encoding::UTF_8)
|
|
88
|
+
source.force_encoding(original_encoding) unless source.valid_encoding?
|
|
89
|
+
end
|
|
90
|
+
source.delete_prefix("")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def next_line
|
|
94
|
+
if @lines.empty?
|
|
95
|
+
@line = nil
|
|
96
|
+
else
|
|
97
|
+
@line = @lines.shift
|
|
98
|
+
@lineno += 1
|
|
99
|
+
@scanner.string = @line
|
|
100
|
+
@outline << LineInfo.new(:continuation, nil, @structure_lineno) if @outline
|
|
101
|
+
@line
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Continuation lines are read stripped at both ends.
|
|
106
|
+
def expect_next_line
|
|
107
|
+
next_line || syntax_error!("Unexpected end of file")
|
|
108
|
+
@scanner.string = @line.strip
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def outline_line(kind)
|
|
112
|
+
@outline[@lineno].kind = kind if @outline
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Characters of the line read so far, even when the scanner reads a stripped copy or attribute source.
|
|
116
|
+
def column
|
|
117
|
+
@line.size - (@scanner.string.size - @scanner.charpos)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def located(node, lineno = @lineno)
|
|
121
|
+
@line_by_node[node] = lineno if @outline
|
|
122
|
+
node
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Counts bytes rather than searching for a regexp, which would allocate a MatchData for every line.
|
|
126
|
+
def indent_of(line)
|
|
127
|
+
indent = 0
|
|
128
|
+
indent += 1 while line.getbyte(indent) == SPACE_BYTE
|
|
129
|
+
return indent unless line.getbyte(indent) == TAB_BYTE
|
|
130
|
+
|
|
131
|
+
line[/\A[ \t]*/].gsub(TAB, "\\1#{" " * TAB_SIZE}").size
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def parse_line
|
|
135
|
+
if @line.match?(BLANK_LINE)
|
|
136
|
+
outline_line(:blank)
|
|
137
|
+
return @stacks.last << NEWLINE
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
indent = indent_of(@line)
|
|
141
|
+
@indents << indent if @indents.empty?
|
|
142
|
+
@scanner.skip(WHITESPACE)
|
|
143
|
+
expecting_indentation = @stacks.size > @indents.size
|
|
144
|
+
|
|
145
|
+
if indent > @indents.last
|
|
146
|
+
syntax_error!("Unexpected indentation") unless expecting_indentation
|
|
147
|
+
@indents << indent
|
|
148
|
+
else
|
|
149
|
+
@stacks.pop if expecting_indentation
|
|
150
|
+
while indent < @indents.last && @indents.size > 1
|
|
151
|
+
@indents.pop
|
|
152
|
+
@stacks.pop
|
|
153
|
+
end
|
|
154
|
+
syntax_error!("Malformed indentation") if indent != @indents.last
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
if @outline
|
|
158
|
+
@structure_lineno = @lineno
|
|
159
|
+
@outline[@lineno] = LineInfo.new(:structure, @indents.size - 1)
|
|
160
|
+
end
|
|
161
|
+
parse_line_indicators
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def parse_line_indicators
|
|
165
|
+
case @scanner.peek_byte
|
|
166
|
+
when SLASH then parse_comment
|
|
167
|
+
when PIPE, APOSTROPHE then parse_text_line
|
|
168
|
+
when LESS_THAN then parse_inline_html
|
|
169
|
+
when DASH then parse_control
|
|
170
|
+
when EQUALS then parse_output
|
|
171
|
+
else parse_tag_line
|
|
172
|
+
end
|
|
173
|
+
@stacks.last << NEWLINE
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def parse_comment
|
|
177
|
+
if @scanner.skip(HTML_COMMENT)
|
|
178
|
+
text_indent = @indents.last + @scanner[1].size + 2
|
|
179
|
+
@stacks.last << Comment.new(parse_text_block(@scanner.rest, text_indent))
|
|
180
|
+
elsif @scanner.match?(CONDITIONAL_COMMENT)
|
|
181
|
+
syntax_error!("Conditional comments are not supported")
|
|
182
|
+
else
|
|
183
|
+
skip_comment_block
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def parse_text_line
|
|
188
|
+
@scanner.skip(TEXT_LINE_INDICATOR)
|
|
189
|
+
indicator = @scanner[1]
|
|
190
|
+
markers = @scanner[2]
|
|
191
|
+
leading_whitespace = markers.include?("<")
|
|
192
|
+
trailing_whitespace = indicator == "'" || markers.include?(">")
|
|
193
|
+
@stacks.last << Static.new(" ") if leading_whitespace
|
|
194
|
+
lineno = @lineno
|
|
195
|
+
@stacks.last << located(TextBlock.new(parse_text_block(@scanner.rest, @indents.last + markers.count(" ") + 1)), lineno)
|
|
196
|
+
@stacks.last << Static.new(" ") if trailing_whitespace
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def parse_inline_html
|
|
200
|
+
children = []
|
|
201
|
+
@stacks.last << located(InlineHtml.new(@scanner.rest, children))
|
|
202
|
+
@stacks << children
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def parse_control
|
|
206
|
+
lineno = @lineno
|
|
207
|
+
@scanner.pos += 1
|
|
208
|
+
children = []
|
|
209
|
+
@stacks.last << located(Control.new(parse_broken_line, children), lineno)
|
|
210
|
+
@stacks << children
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def parse_output
|
|
214
|
+
position = @scanner.pos + 1
|
|
215
|
+
escape = @line.getbyte(position) != EQUALS
|
|
216
|
+
position += 1 unless escape
|
|
217
|
+
leading_whitespace = trailing_whitespace = false
|
|
218
|
+
while (byte = @line.getbyte(position)) == LESS_THAN || byte == GREATER_THAN
|
|
219
|
+
leading_whitespace ||= byte == LESS_THAN
|
|
220
|
+
trailing_whitespace ||= byte == GREATER_THAN
|
|
221
|
+
position += 1
|
|
222
|
+
end
|
|
223
|
+
@scanner.pos = position
|
|
224
|
+
children = []
|
|
225
|
+
@stacks.last << Static.new(" ") if leading_whitespace
|
|
226
|
+
lineno = @lineno
|
|
227
|
+
@stacks.last << located(Output.new(parse_broken_line, escape, children), lineno)
|
|
228
|
+
@stacks.last << Static.new(" ") if trailing_whitespace
|
|
229
|
+
@stacks << children
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def parse_tag_line
|
|
233
|
+
if @scanner.skip(ENGINE)
|
|
234
|
+
@stacks.last << parse_embedded(@scanner[1], @scanner[2], @scanner.rest)
|
|
235
|
+
elsif @scanner.skip(DOCTYPE)
|
|
236
|
+
@stacks.last << Doctype.new(@scanner.rest.strip)
|
|
237
|
+
elsif (tag = scan_tag)
|
|
238
|
+
parse_tag(tag)
|
|
239
|
+
else
|
|
240
|
+
syntax_error!("Unknown line indicator")
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# A tag name is consumed with its match; a shortcut is left for parse_tag to read as an attribute.
|
|
245
|
+
def scan_tag
|
|
246
|
+
tag = @scanner.check(@tag) or return
|
|
247
|
+
@scanner.pos += tag.bytesize if @scanner[1]
|
|
248
|
+
tag
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def parse_embedded(engine, attributes_source, text)
|
|
252
|
+
syntax_error!("Embedded engine #{engine} is not supported") if UNSUPPORTED_ENGINES.include?(engine)
|
|
253
|
+
|
|
254
|
+
lineno = @lineno
|
|
255
|
+
text_indent = @line.size - text.size + attributes_source.size
|
|
256
|
+
@scanner.string = attributes_source
|
|
257
|
+
attributes = parse_attributes
|
|
258
|
+
located Embedded.new(engine, parse_text_block(text, text_indent), attributes), lineno
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def skip_comment_block
|
|
262
|
+
while !@lines.empty? && (@lines.first.match?(BLANK_LINE) || indent_of(@lines.first) > @indents.last)
|
|
263
|
+
next_line
|
|
264
|
+
@stacks.last << NEWLINE
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def parse_text_block(first_line = nil, text_indent = nil)
|
|
269
|
+
nodes = []
|
|
270
|
+
if !first_line || first_line.empty?
|
|
271
|
+
text_indent = nil
|
|
272
|
+
else
|
|
273
|
+
nodes << Text.new(first_line)
|
|
274
|
+
@outline[@lineno].text = true if @outline
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
empty_lines = 0
|
|
278
|
+
# Blank lines inside a text block render only when more text follows them.
|
|
279
|
+
rendered_blank_linenos = [] if @outline
|
|
280
|
+
until @lines.empty?
|
|
281
|
+
if @lines.first.match?(BLANK_LINE)
|
|
282
|
+
next_line
|
|
283
|
+
nodes << NEWLINE
|
|
284
|
+
if text_indent
|
|
285
|
+
empty_lines += 1
|
|
286
|
+
rendered_blank_linenos << @lineno if @outline
|
|
287
|
+
else
|
|
288
|
+
outline_line(:blank)
|
|
289
|
+
end
|
|
290
|
+
else
|
|
291
|
+
indent = indent_of(@lines.first)
|
|
292
|
+
break if indent <= @indents.last
|
|
293
|
+
|
|
294
|
+
if empty_lines > 0
|
|
295
|
+
nodes << Text.new("\n" * empty_lines)
|
|
296
|
+
empty_lines = 0
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
if @outline
|
|
300
|
+
rendered_blank_linenos.each { @outline[it].kind = :text_blank }
|
|
301
|
+
rendered_blank_linenos.clear
|
|
302
|
+
end
|
|
303
|
+
next_line
|
|
304
|
+
outline_line(:text)
|
|
305
|
+
|
|
306
|
+
offset = text_indent ? indent - text_indent : 0
|
|
307
|
+
if offset < 0
|
|
308
|
+
text_indent += offset
|
|
309
|
+
offset = 0
|
|
310
|
+
end
|
|
311
|
+
nodes << NEWLINE << Text.new("#{"\n" if text_indent}#{" " * offset}#{@line.lstrip}")
|
|
312
|
+
text_indent ||= indent
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
rendered_blank_linenos.each { @outline[it].kind = :blank } if @outline
|
|
316
|
+
nodes
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def parse_broken_line
|
|
320
|
+
broken_line = @scanner.rest.strip
|
|
321
|
+
while broken_line.match?(LINE_CONTINUATION)
|
|
322
|
+
expect_next_line
|
|
323
|
+
broken_line << "\n" << @scanner.string
|
|
324
|
+
end
|
|
325
|
+
broken_line
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def parse_tag(tag)
|
|
329
|
+
tag_lineno = @lineno
|
|
330
|
+
if (shortcut_tag = @shortcuts.tag_by_shortcut[tag])
|
|
331
|
+
@scanner.pos += tag.bytesize unless @shortcuts.attributes_by_shortcut[tag]
|
|
332
|
+
tag = shortcut_tag
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
attributes = []
|
|
336
|
+
# Read before it is consumed, so that an illegal shortcut is reported where it starts.
|
|
337
|
+
while (shortcut_length = @scanner.match?(@shortcuts.attribute_pattern))
|
|
338
|
+
shortcut = @scanner[1]
|
|
339
|
+
value = @scanner[2]
|
|
340
|
+
syntax_error!("Illegal shortcut") unless (names = @shortcuts.attributes_by_shortcut[shortcut])
|
|
341
|
+
|
|
342
|
+
# Tailwind arbitrary values may hold quotes and ampersands, which Slim names never do.
|
|
343
|
+
value = ERB::Escape.html_escape(value) if value.match?(SHORTCUT_VALUE_TO_ESCAPE)
|
|
344
|
+
names.each { attributes << StaticAttribute.new(it, value) }
|
|
345
|
+
@shortcuts.additional_attributes_by_shortcut[shortcut]&.each { |name, value| attributes << StaticAttribute.new(name.to_s, value) }
|
|
346
|
+
@scanner.pos += shortcut_length
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
leading_whitespace = trailing_whitespace = false
|
|
350
|
+
if WHITESPACE_MARKER_BYTES.include?(@scanner.peek_byte)
|
|
351
|
+
markers = @scanner.scan(WHITESPACE_MARKERS)
|
|
352
|
+
trailing_whitespace = markers.include?(">")
|
|
353
|
+
leading_whitespace = markers.include?("<")
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
parse_attributes(attributes)
|
|
357
|
+
|
|
358
|
+
siblings = @stacks.last
|
|
359
|
+
content, block, output_whitespace = parse_tag_content
|
|
360
|
+
siblings << Static.new(" ") if leading_whitespace
|
|
361
|
+
siblings << located(Tag.new(tag, attributes, content), tag_lineno)
|
|
362
|
+
siblings << Static.new(" ") if trailing_whitespace
|
|
363
|
+
if output_whitespace
|
|
364
|
+
output_leading_whitespace, output_trailing_whitespace = output_whitespace
|
|
365
|
+
siblings.insert(-2, Static.new(" ")) if output_leading_whitespace && !leading_whitespace
|
|
366
|
+
siblings << Static.new(" ") if output_trailing_whitespace && !trailing_whitespace
|
|
367
|
+
end
|
|
368
|
+
@stacks << block if block
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Returns the tag content, the block expecting indented children, and the whitespace an `=<>` output adds.
|
|
372
|
+
def parse_tag_content
|
|
373
|
+
output_column = column if @outline
|
|
374
|
+
if @scanner.skip(NESTED_TAG)
|
|
375
|
+
return [[parse_embedded(@scanner[1], @scanner[2], @scanner.rest)]] if @scanner.skip(ENGINE)
|
|
376
|
+
|
|
377
|
+
(tag = scan_tag) || syntax_error!("Expected tag")
|
|
378
|
+
content = []
|
|
379
|
+
@stacks << content
|
|
380
|
+
stack_index = @stacks.size - 1
|
|
381
|
+
parse_tag(tag)
|
|
382
|
+
@stacks.delete_at(stack_index)
|
|
383
|
+
[content]
|
|
384
|
+
elsif @scanner.skip(TAG_OUTPUT)
|
|
385
|
+
@outline[@lineno].output_column = output_column if @outline
|
|
386
|
+
escape = @scanner[1] != "="
|
|
387
|
+
markers = @scanner[2]
|
|
388
|
+
output_whitespace = [markers.include?("<"), markers.include?(">")]
|
|
389
|
+
children = []
|
|
390
|
+
lineno = @lineno
|
|
391
|
+
[[located(Output.new(parse_broken_line, escape, children), lineno)], children, output_whitespace]
|
|
392
|
+
elsif @scanner.skip(CLOSED_TAG)
|
|
393
|
+
syntax_error!("Unexpected text after closed tag") unless @scanner.eos?
|
|
394
|
+
[nil]
|
|
395
|
+
elsif @scanner.match?(BLANK_REST)
|
|
396
|
+
content = []
|
|
397
|
+
[content, content]
|
|
398
|
+
else
|
|
399
|
+
@scanner.pos += 1 if @scanner.peek_byte == SPACE_BYTE
|
|
400
|
+
text_indent = column
|
|
401
|
+
[[TextBlock.new(parse_text_block(@scanner.rest, text_indent))]]
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def parse_attributes(attributes = [])
|
|
406
|
+
closing = DELIMITERS[@scanner[1]] if @scanner.skip(ATTRIBUTE_LIST_OPENING)
|
|
407
|
+
|
|
408
|
+
loop do
|
|
409
|
+
if @scanner.skip(SPLAT)
|
|
410
|
+
attributes << Splat.new(parse_ruby_code(closing))
|
|
411
|
+
elsif @scanner.skip(QUOTED_ATTRIBUTE)
|
|
412
|
+
name = @scanner[1]
|
|
413
|
+
escape = @scanner[2].empty?
|
|
414
|
+
attributes << QuotedAttribute.new(name, escape, parse_quoted_attribute(@scanner[3]))
|
|
415
|
+
elsif @scanner.skip(CODE_ATTRIBUTE)
|
|
416
|
+
name = @scanner[1]
|
|
417
|
+
escape = @scanner[2].empty?
|
|
418
|
+
code = parse_ruby_code(closing)
|
|
419
|
+
syntax_error!("Invalid empty attribute") if code.empty?
|
|
420
|
+
attributes << CodeAttribute.new(name, escape, code)
|
|
421
|
+
else
|
|
422
|
+
break unless closing
|
|
423
|
+
|
|
424
|
+
if @scanner.skip(BOOLEAN_ATTRIBUTE_BY_CLOSING[closing])
|
|
425
|
+
attributes << BooleanAttribute.new(@scanner[1])
|
|
426
|
+
elsif @scanner.skip(ATTRIBUTE_LIST_END_BY_CLOSING[closing])
|
|
427
|
+
break
|
|
428
|
+
else
|
|
429
|
+
@scanner.skip(WHITESPACE)
|
|
430
|
+
syntax_error!("Expected attribute") unless @scanner.eos?
|
|
431
|
+
@stacks.last << NEWLINE
|
|
432
|
+
syntax_error!("Expected closing delimiter #{closing}") if @lines.empty?
|
|
433
|
+
next_line
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
attributes
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def parse_ruby_code(outer_closing)
|
|
441
|
+
code = +""
|
|
442
|
+
depth = 0
|
|
443
|
+
opening = closing = nil
|
|
444
|
+
code_end = CODE_END_BY_CLOSING[outer_closing]
|
|
445
|
+
|
|
446
|
+
until @scanner.eos? || (depth == 0 && @scanner.match?(code_end))
|
|
447
|
+
if @scanner.match?(LINE_CONTINUATION)
|
|
448
|
+
code << @scanner.getch << "\n"
|
|
449
|
+
expect_next_line
|
|
450
|
+
elsif (chunk = @scanner.scan(depth == 0 ? CODE_CHUNK_BY_CLOSING[outer_closing] : NESTED_CODE_CHUNK_BY_OPENING[opening]))
|
|
451
|
+
code << chunk
|
|
452
|
+
else
|
|
453
|
+
character = @scanner.getch
|
|
454
|
+
if depth > 0
|
|
455
|
+
depth += character == opening ? 1 : -1
|
|
456
|
+
else
|
|
457
|
+
depth = 1
|
|
458
|
+
opening = character
|
|
459
|
+
closing = DELIMITERS[opening]
|
|
460
|
+
end
|
|
461
|
+
code << character
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
syntax_error!("Expected closing delimiter #{closing}") if depth != 0
|
|
465
|
+
code
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def parse_quoted_attribute(quote)
|
|
469
|
+
value = +""
|
|
470
|
+
depth = 0
|
|
471
|
+
quote_byte = quote.ord
|
|
472
|
+
|
|
473
|
+
until depth == 0 && @scanner.peek_byte == quote_byte
|
|
474
|
+
if @scanner.eos? || @scanner.match?(BACKSLASH_AT_END)
|
|
475
|
+
value << (@scanner.eos? ? "\n" : " ")
|
|
476
|
+
@stacks.last << NEWLINE
|
|
477
|
+
expect_next_line
|
|
478
|
+
elsif (chunk = @scanner.scan(QUOTED_CHUNK_BY_QUOTE[quote]))
|
|
479
|
+
value << chunk
|
|
480
|
+
else
|
|
481
|
+
byte = @scanner.peek_byte
|
|
482
|
+
depth += 1 if byte == OPENING_BRACE
|
|
483
|
+
depth -= 1 if byte == CLOSING_BRACE
|
|
484
|
+
value << @scanner.getch
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
@scanner.pos += 1
|
|
489
|
+
value
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def syntax_error!(message)
|
|
493
|
+
error = SyntaxError.new(message, @file, @line, @lineno, @line && @scanner ? column : 0)
|
|
494
|
+
error.set_backtrace(["#{@file}:#{@lineno}", *caller])
|
|
495
|
+
raise error
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlimX
|
|
4
|
+
class RailsHandler
|
|
5
|
+
def call(template, source = nil)
|
|
6
|
+
SlimX.compile(
|
|
7
|
+
source || template.source,
|
|
8
|
+
file: template.identifier,
|
|
9
|
+
format: xml?(template) ? :xml : :html,
|
|
10
|
+
rails: true,
|
|
11
|
+
**annotations(template),
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def supports_streaming?
|
|
16
|
+
true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def xml?(template)
|
|
22
|
+
template.format && ::Mime[template.format].to_s.end_with?("xml")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def annotations(template)
|
|
26
|
+
return {} unless ActionView::Base.annotate_rendered_view_with_filenames && template.format == :html
|
|
27
|
+
|
|
28
|
+
{ preamble: "<!-- BEGIN #{template.short_identifier} -->\n", postamble: "<!-- END #{template.short_identifier} -->\n" }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlimX
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
initializer "slimx.register_template_handler" do
|
|
6
|
+
ActiveSupport.on_load(:action_view) do
|
|
7
|
+
require_relative "rails_handler"
|
|
8
|
+
ActionView::Template.register_template_handler(:slim, SlimX::RailsHandler.new)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb/escape"
|
|
4
|
+
|
|
5
|
+
module SlimX
|
|
6
|
+
module Runtime
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def escape_html(value)
|
|
10
|
+
ERB::Escape.html_escape(value.to_s)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def escape_html_safe(value)
|
|
14
|
+
string = value.to_s
|
|
15
|
+
string.html_safe? || value.html_safe? ? string : ERB::Escape.html_escape(string)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def class_value(value, escape, html_safe)
|
|
19
|
+
value = value.flatten.map(&:to_s).reject(&:empty?).join(" ") if value.is_a?(Array)
|
|
20
|
+
return value.to_s unless escape
|
|
21
|
+
|
|
22
|
+
html_safe ? escape_html_safe(value) : escape_html(value)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def join_classes(*values)
|
|
26
|
+
values.reject(&:empty?).join(" ")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Attributes only known at render time: splats (`*hash`), `data=`/`aria=` hashes and dynamic tags (`*{tag: "a"}`).
|
|
30
|
+
class Attributes
|
|
31
|
+
INVALID_NAME = /[ \0"'>\/=]/
|
|
32
|
+
HYPHENATED = %w[data aria].freeze
|
|
33
|
+
|
|
34
|
+
def initialize(html_safe)
|
|
35
|
+
@html_safe = html_safe
|
|
36
|
+
@value_by_name = {}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def code_attr(name, escape, value)
|
|
40
|
+
if name == "class"
|
|
41
|
+
value = value.is_a?(Array) ? value.join(" ") : value.to_s
|
|
42
|
+
attr(name, escape(escape, value)) unless value.empty?
|
|
43
|
+
elsif HYPHENATED.include?(name) && value.is_a?(Hash)
|
|
44
|
+
hyphenated_attr(name, escape, value)
|
|
45
|
+
elsif value != false && !value.nil?
|
|
46
|
+
attr(name, escape(escape, value))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def splat_attrs(splat)
|
|
51
|
+
splat.each { |name, value| code_attr(name.is_a?(Symbol) ? name.name : name.to_s, true, value) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def attr(name, value)
|
|
55
|
+
raise InvalidAttributeNameError, "Invalid attribute name '#{name}' was rendered" if name.match?(INVALID_NAME)
|
|
56
|
+
|
|
57
|
+
if !@value_by_name.key?(name)
|
|
58
|
+
@value_by_name[name] = value
|
|
59
|
+
elsif name == "class"
|
|
60
|
+
@value_by_name[name] = "#{@value_by_name[name]} #{value}"
|
|
61
|
+
else
|
|
62
|
+
raise "Multiple #{name} attributes specified"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def build_tag(content = nil)
|
|
67
|
+
tag = @value_by_name.delete("tag").to_s
|
|
68
|
+
tag = "div" if tag.empty?
|
|
69
|
+
content ? "<#{tag}#{self}>#{content}</#{tag}>" : "<#{tag}#{self} />"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def to_s
|
|
73
|
+
html = +""
|
|
74
|
+
@value_by_name.each do |name, value|
|
|
75
|
+
html << " " << name
|
|
76
|
+
html << %(=") << value.to_s << %(") unless value == true
|
|
77
|
+
end
|
|
78
|
+
html
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def hyphenated_attr(name, escape, value)
|
|
84
|
+
if value.is_a?(Hash)
|
|
85
|
+
value.each { |key, nested_value| hyphenated_attr("#{name}-#{key}", escape, nested_value) }
|
|
86
|
+
else
|
|
87
|
+
attr(name, escape(escape, value))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def escape(escape, value)
|
|
92
|
+
return value if !escape || value == true
|
|
93
|
+
|
|
94
|
+
@html_safe ? Runtime.escape_html_safe(value) : Runtime.escape_html(value)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlimX
|
|
4
|
+
class Shortcuts
|
|
5
|
+
DEFAULT = { "#" => { attr: "id" }, "." => { attr: "class" } }.freeze
|
|
6
|
+
DEFAULT_TAG = "div"
|
|
7
|
+
|
|
8
|
+
attr_reader :tag_by_shortcut, :attributes_by_shortcut, :additional_attributes_by_shortcut, :attribute_pattern, :tag_pattern
|
|
9
|
+
|
|
10
|
+
SLIM_NAME_PART = /\p{Word}|-|\/\d+|:(?:\w|-)+/
|
|
11
|
+
# Tailwind class names: arbitrary values in brackets (one level of nesting, no whitespace), CSS variables in
|
|
12
|
+
# parentheses after a dash, variants joined by `:` (`: ` with a space still nests a tag), `!` and `@`,
|
|
13
|
+
# `*:` child variants, fractions and opacities after `/` (a final `/` still closes the tag), and decimals
|
|
14
|
+
# such as `py-1.5`, where a dot between two digits does not start a new class.
|
|
15
|
+
# Tailwind never puts a bracket right after a letter or a digit: `.btn[href="/"]` stays an attribute list.
|
|
16
|
+
TAILWIND_NAME_PART = %r{
|
|
17
|
+
\p{Word} | - | ! | @ | \*(?=:)
|
|
18
|
+
| :(?=[\p{Word}\[(!@*-])
|
|
19
|
+
| /(?=[\p{Word}\[(])
|
|
20
|
+
| (?<=\d)\.(?=\d)
|
|
21
|
+
| (?<![\p{Word}\])]) \[ (?:[^\[\]\s] | \[[^\[\]\s]*\])+ \]
|
|
22
|
+
| (?<=-)\( (?:[^()\s] | \([^()\s]*\))+ \)
|
|
23
|
+
}x
|
|
24
|
+
|
|
25
|
+
@shortcuts_by_definitions = {}
|
|
26
|
+
|
|
27
|
+
def self.for(definitions, tailwind_classes: false)
|
|
28
|
+
@shortcuts_by_definitions[[definitions, tailwind_classes]] ||= new(definitions, tailwind_classes:).freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(definitions, tailwind_classes: false)
|
|
32
|
+
@tag_by_shortcut = {}
|
|
33
|
+
@attributes_by_shortcut = {}
|
|
34
|
+
@additional_attributes_by_shortcut = {}
|
|
35
|
+
definitions.each { |shortcut, definition| register(shortcut, definition) }
|
|
36
|
+
|
|
37
|
+
attribute_keys = Regexp.union(@attributes_by_shortcut.keys.sort_by { -it.size })
|
|
38
|
+
name_part = tailwind_classes ? TAILWIND_NAME_PART : SLIM_NAME_PART
|
|
39
|
+
@attribute_pattern = /\A(#{attribute_keys}+)((?:#{name_part})*)/
|
|
40
|
+
tag_keys = Regexp.union(@tag_by_shortcut.keys.sort_by { -it.size })
|
|
41
|
+
@tag_pattern = /\A(?:#{tag_keys}|\*(?=[^\s]+)|(\p{Word}(?:\p{Word}|:|-)*\p{Word}|\p{Word}+))/
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def register(shortcut, definition)
|
|
47
|
+
unless (definition[:attr] || definition[:tag]) && (definition.keys - %i[attr tag additional_attrs]).empty?
|
|
48
|
+
raise ArgumentError, "Shortcut requires :tag and/or :attr"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
raise ArgumentError, "Lambda shortcuts are not supported" if definition[:attr].is_a?(Proc)
|
|
52
|
+
|
|
53
|
+
@tag_by_shortcut[shortcut] = definition[:tag] || DEFAULT_TAG
|
|
54
|
+
if (definition.key?(:attr) || definition.key?(:additional_attrs)) && shortcut.match?(/(\p{Word}|-)/)
|
|
55
|
+
raise ArgumentError, "You can only use special characters for attribute shortcuts"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
@attributes_by_shortcut[shortcut] = Array(definition[:attr]) if definition.key?(:attr)
|
|
59
|
+
@additional_attributes_by_shortcut[shortcut] = definition[:additional_attrs] if definition.key?(:additional_attrs)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|