super-slim 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lint_roller"
4
+ require "strscan"
5
+ require_relative "../super_slim"
6
+ require_relative "linter"
7
+
8
+ module SuperSlim
9
+ # Loaded by RuboCop from `plugins: [super_slim]`: lints the Ruby of each template, offenses at their place in the `.slim` file.
10
+ module RuboCop
11
+ class Plugin < LintRoller::Plugin
12
+ def about
13
+ LintRoller::About.new(name: "super_slim", version: VERSION, homepage: "https://github.com/levups/super-slim",
14
+ description: "Lints the Ruby of Slim templates, read by SuperSlim's parser")
15
+ end
16
+
17
+ def supported?(context)
18
+ context.engine == :rubocop
19
+ end
20
+
21
+ def rules(_context)
22
+ ::RuboCop::Runner.ruby_extractors.unshift(RubyExtractor)
23
+ LintRoller::Rules.new(type: :path, config_format: :rubocop, value: File.expand_path("../../config/rubocop.yml", __dir__))
24
+ end
25
+ end
26
+
27
+ # Hands RuboCop each piece of Ruby with its offset: a piece is a slice of the template, so autocorrection
28
+ # writes back into the template.
29
+ class RubyExtractor
30
+ Fragment = Struct.new(:code, :offset)
31
+
32
+ # Slim reads a control line as the head of a block: its keyword and `do` belong to the indented lines.
33
+ LEADING_KEYWORD = /\A(?:begin|case|else|elsif|ensure|if|rescue|unless|until|when|while|for[ \t]+\w+[ \t]+in)\b[ \t]*/
34
+ TRAILING_DO = /(?:\b[ \t]*|[ \t])do[ \t]*(\|[^|]*\|)?[ \t]*(#.*)?\z/
35
+ WHEN = /\A\s*when[ \t]/
36
+ THEN = /[ \t]then(?:[ \t].*)?\z/m
37
+ INTERPOLATION = /#\{((?>[^{}]|(\{(?>[^{}]|\g<1>)*\}))*)\}/
38
+ ESCAPED_INTERPOLATION = /\\#\{/
39
+ UNESCAPED_INTERPOLATION_CODE = /\A\{.*\}\z/m
40
+ TEXT_WITHOUT_INTERPOLATION = /[^#\\]+|./m
41
+
42
+ def self.call(processed_source)
43
+ new(processed_source).call if processed_source.path&.end_with?(".slim")
44
+ end
45
+
46
+ def initialize(processed_source)
47
+ @processed_source = processed_source
48
+ @source = processed_source.raw_source
49
+ end
50
+
51
+ def call
52
+ fragments.filter_map do |fragment|
53
+ next if fragment.code.strip.empty?
54
+
55
+ {offset: fragment.offset, processed_source: processed_source_of(fragment.code)}
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def fragments
62
+ parser = Parser.new(file: @processed_source.path, outline: true, tailwind_classes: tailwind_classes?)
63
+ parser.call(@source)
64
+ parser.ruby_ranges.flat_map do |kind, first, last|
65
+ # From the start of its first line, so that the indentation of a `ruby:` block is the same on every line.
66
+ first = (@source.rindex("\n", first - 1) || -1) + 1 if kind == :embedded_ruby
67
+ fragment = Fragment.new(@source[first...last], first)
68
+ case kind
69
+ when :text then interpolations(fragment)
70
+ when :code then without_block_syntax(fragment)
71
+ when :embedded_ruby then [fragment]
72
+ end
73
+ end
74
+ rescue SyntaxError
75
+ []
76
+ end
77
+
78
+ def tailwind_classes?
79
+ configuration = Linter.configuration
80
+ configuration.key?("tailwind_classes") ? configuration["tailwind_classes"] : SuperSlim.tailwind_classes
81
+ end
82
+
83
+ def interpolations(text)
84
+ scanner = StringScanner.new(text.code)
85
+ fragments = []
86
+ until scanner.eos?
87
+ next if scanner.skip(ESCAPED_INTERPOLATION)
88
+
89
+ if scanner.scan(INTERPOLATION)
90
+ code = scanner[1]
91
+ offset = text.offset + scanner.charpos - code.size - 1
92
+ fragments << (code.match?(UNESCAPED_INTERPOLATION_CODE) ? Fragment.new(code[1..-2], offset + 1) : Fragment.new(code, offset))
93
+ else
94
+ scanner.skip(TEXT_WITHOUT_INTERPOLATION)
95
+ end
96
+ end
97
+ fragments
98
+ end
99
+
100
+ def without_block_syntax(fragment)
101
+ whens(fragment).map do |clause|
102
+ keyword = clause.code[LEADING_KEYWORD].to_s
103
+ Fragment.new(clause.code[keyword.size..].sub(TRAILING_DO, ""), clause.offset + keyword.size)
104
+ end
105
+ end
106
+
107
+ # `when a, b` holds two expressions, which RuboCop reads as an array: each one becomes a fragment.
108
+ def whens(fragment)
109
+ keyword = fragment.code[WHEN] or return [fragment]
110
+ conditions = fragment.code[keyword.size..].sub(THEN, "")
111
+ array = processed_source_of("[#{conditions}]").ast
112
+ return [fragment] unless array&.array_type?
113
+
114
+ array.children.map do |condition|
115
+ Fragment.new(condition.source, fragment.offset + keyword.size + condition.source_range.begin_pos - 1)
116
+ end
117
+ end
118
+
119
+ def processed_source_of(code)
120
+ processed_source = ::RuboCop::ProcessedSource.new(code, @processed_source.ruby_version, @processed_source.path,
121
+ parser_engine: @processed_source.parser_engine)
122
+ processed_source.config = @processed_source.config
123
+ processed_source.registry = @processed_source.registry
124
+ processed_source
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb/escape"
4
+
5
+ module SuperSlim
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
+ if value.is_a?(Array)
20
+ value = value.flatten.filter_map do |element|
21
+ string = element.to_s
22
+ string unless string.empty?
23
+ end.join(" ")
24
+ end
25
+ return value.to_s unless escape
26
+
27
+ html_safe ? escape_html_safe(value) : escape_html(value)
28
+ end
29
+
30
+ def join_classes(*values)
31
+ values.reject(&:empty?).join(" ")
32
+ end
33
+
34
+ # Attributes only known at render time: splats (`*hash`), `data=`/`aria=` hashes and dynamic tags (`*{tag: "a"}`).
35
+ class Attributes
36
+ INVALID_NAME = /[ \0"'>\/=]/
37
+ HYPHENATED = %w[data aria].freeze
38
+
39
+ def initialize(html_safe)
40
+ @html_safe = html_safe
41
+ @value_by_name = {}
42
+ end
43
+
44
+ def code_attr(name, escape, value)
45
+ if name == "class"
46
+ value = value.is_a?(Array) ? value.join(" ") : value.to_s
47
+ attr(name, escape(escape, value)) unless value.empty?
48
+ elsif HYPHENATED.include?(name) && value.is_a?(Hash)
49
+ hyphenated_attr(name, escape, value)
50
+ elsif value != false && !value.nil?
51
+ attr(name, escape(escape, value))
52
+ end
53
+ end
54
+
55
+ def splat_attrs(splat)
56
+ splat.each { |name, value| code_attr(name.is_a?(Symbol) ? name.name : name.to_s, true, value) }
57
+ end
58
+
59
+ def attr(name, value)
60
+ raise InvalidAttributeNameError, "Invalid attribute name '#{name}' was rendered" if name.match?(INVALID_NAME)
61
+
62
+ if !@value_by_name.key?(name)
63
+ @value_by_name[name] = value
64
+ elsif name == "class"
65
+ @value_by_name[name] = "#{@value_by_name[name]} #{value}"
66
+ else
67
+ raise "Multiple #{name} attributes specified"
68
+ end
69
+ end
70
+
71
+ def build_tag(content = nil)
72
+ tag = @value_by_name.delete("tag").to_s
73
+ tag = "div" if tag.empty?
74
+ content ? "<#{tag}#{self}>#{content}</#{tag}>" : "<#{tag}#{self} />"
75
+ end
76
+
77
+ def to_s
78
+ html = +""
79
+ @value_by_name.each do |name, value|
80
+ html << " " << name
81
+ html << %(=") << value.to_s << %(") unless value == true
82
+ end
83
+ html
84
+ end
85
+
86
+ private
87
+
88
+ def hyphenated_attr(name, escape, value)
89
+ if value.is_a?(Hash)
90
+ value.each { |key, nested_value| hyphenated_attr("#{name}-#{key}", escape, nested_value) }
91
+ else
92
+ attr(name, escape(escape, value))
93
+ end
94
+ end
95
+
96
+ def escape(escape, value)
97
+ return value if !escape || value == true
98
+
99
+ @html_safe ? Runtime.escape_html_safe(value) : Runtime.escape_html(value)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperSlim
4
+ # `#name` sets the id and `.name` a class, on a div unless a tag name comes first.
5
+ module Shortcuts
6
+ ATTRIBUTE_BY_SHORTCUT = {"#" => "id", "." => "class"}.freeze
7
+ DEFAULT_TAG = "div"
8
+
9
+ SLIM_NAME_PART = /\p{Word}|-|\/\d+|:(?:\w|-)+/
10
+ # Tailwind class names: arbitrary values in brackets (one level of nesting, no whitespace), CSS variables in
11
+ # parentheses after a dash, variants joined by `:` (`: ` with a space still nests a tag), `!` and `@`,
12
+ # `*:` child variants, fractions and opacities after `/` (a final `/` still closes the tag), and decimals
13
+ # such as `py-1.5`, where a dot between two digits does not start a new class.
14
+ # Tailwind never puts a bracket right after a letter or a digit: `.btn[href="/"]` stays an attribute list.
15
+ TAILWIND_NAME_PART = %r{
16
+ \p{Word} | - | ! | @ | \*(?=:)
17
+ | :(?=[\p{Word}\[(!@*-])
18
+ | /(?=[\p{Word}\[(])
19
+ | (?<=\d)\.(?=\d)
20
+ | (?<![\p{Word}\])]) \[ (?:[^\[\]\s] | \[[^\[\]\s]*\])+ \]
21
+ | (?<=-)\( (?:[^()\s] | \([^()\s]*\))+ \)
22
+ }x
23
+
24
+ ATTRIBUTE_PATTERN = /\A([#.]+)((?:#{SLIM_NAME_PART})*)/
25
+ TAILWIND_ATTRIBUTE_PATTERN = /\A([#.]+)((?:#{TAILWIND_NAME_PART})*)/
26
+ TAG_PATTERN = /\A(?:[#.]|\*(?=[^\s]+)|(\p{Word}(?:\p{Word}|:|-)*\p{Word}|\p{Word}+))/
27
+ end
28
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperSlim
4
+ VERSION = "3.0.0"
5
+ end
data/lib/super_slim.rb ADDED
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "super_slim/version"
4
+ require_relative "super_slim/ast"
5
+ require_relative "super_slim/shortcuts"
6
+ require_relative "super_slim/parser"
7
+ require_relative "super_slim/compiler"
8
+ require_relative "super_slim/runtime"
9
+
10
+ module SuperSlim
11
+ autoload :Formatter, "super_slim/formatter"
12
+ autoload :RuboCop, "super_slim/rubocop"
13
+
14
+ class Error < StandardError; end
15
+ class InvalidAttributeNameError < Error; end
16
+ class UnsafeFormattingError < Error; end
17
+
18
+ class SyntaxError < Error
19
+ attr_reader :error, :file, :line, :lineno, :column
20
+
21
+ def initialize(error, file, line, lineno, column)
22
+ @error = error
23
+ @file = file || "(__TEMPLATE__)"
24
+ @line = line.to_s
25
+ @lineno = lineno
26
+ @column = column
27
+ super(to_s)
28
+ end
29
+
30
+ def to_s
31
+ line = @line.lstrip
32
+ column = @column + line.size - @line.size
33
+ <<~MESSAGE
34
+ #{error}
35
+ #{file}, Line #{lineno}, Column #{@column}
36
+ #{line}
37
+ #{" " * column}^
38
+ MESSAGE
39
+ end
40
+ end
41
+
42
+ class << self
43
+ # Shortcuts also read Tailwind class names, such as `.rounded-b-[3px]` or `.lg:hover:underline`. Off by
44
+ # default so that switching from Slim changes no template: Slim renders `.size-2.5` as two classes.
45
+ attr_accessor :tailwind_classes
46
+
47
+ def compile(source, file: nil, tailwind_classes: self.tailwind_classes, **compiler_options)
48
+ Compiler.new(**compiler_options).call(Parser.new(file:, tailwind_classes:).call(source))
49
+ end
50
+ end
51
+ end
52
+
53
+ require_relative "super_slim/railtie" if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: super-slim
3
+ version: !ruby/object:Gem::Version
4
+ version: 3.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Level UP Solutions
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: strscan
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 3.1.1
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 3.1.1
26
+ description: 'A Slim template engine rewritten from scratch, without Temple: same
27
+ HTML as Slim 5, faster compilation and rendering with fewer allocations, a formatter
28
+ that cannot change a page, a linter with HTML and accessibility rules, and a RuboCop
29
+ plugin for the Ruby inside templates.'
30
+ executables:
31
+ - super-slim
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - CHANGELOG.md
36
+ - LICENSE
37
+ - LICENSE-slim
38
+ - README.md
39
+ - config/rubocop.yml
40
+ - exe/super-slim
41
+ - lib/super-slim.rb
42
+ - lib/super_slim.rb
43
+ - lib/super_slim/ast.rb
44
+ - lib/super_slim/cli.rb
45
+ - lib/super_slim/compiler.rb
46
+ - lib/super_slim/formatter.rb
47
+ - lib/super_slim/formatter/line_wrapper.rb
48
+ - lib/super_slim/linter.rb
49
+ - lib/super_slim/linter/rules.rb
50
+ - lib/super_slim/parallel.rb
51
+ - lib/super_slim/parser.rb
52
+ - lib/super_slim/rails_handler.rb
53
+ - lib/super_slim/railtie.rb
54
+ - lib/super_slim/rubocop.rb
55
+ - lib/super_slim/runtime.rb
56
+ - lib/super_slim/shortcuts.rb
57
+ - lib/super_slim/version.rb
58
+ homepage: https://github.com/levups/super-slim
59
+ licenses:
60
+ - MIT
61
+ metadata:
62
+ source_code_uri: https://github.com/levups/super-slim
63
+ changelog_uri: https://github.com/levups/super-slim/blob/main/CHANGELOG.md
64
+ documentation_uri: https://levups.github.io/super-slim/
65
+ bug_tracker_uri: https://github.com/levups/super-slim/issues
66
+ rubygems_mfa_required: 'true'
67
+ default_lint_roller_plugin: SuperSlim::RuboCop::Plugin
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '3.3'
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 4.0.24
83
+ specification_version: 4
84
+ summary: 'Slim templates, rewritten: faster to compile and render, with a formatter
85
+ and a linter'
86
+ test_files: []