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.
@@ -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