theme-check 0.10.1 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +2 -6
  3. data/CHANGELOG.md +41 -0
  4. data/README.md +39 -0
  5. data/RELEASING.md +34 -2
  6. data/Rakefile +1 -1
  7. data/config/default.yml +39 -3
  8. data/config/nothing.yml +11 -0
  9. data/config/theme_app_extension.yml +153 -0
  10. data/data/shopify_liquid/objects.yml +2 -0
  11. data/docs/checks/asset_size_app_block_css.md +52 -0
  12. data/docs/checks/asset_size_app_block_javascript.md +57 -0
  13. data/docs/checks/asset_size_css_stylesheet_tag.md +50 -0
  14. data/docs/checks/deprecate_bgsizes.md +66 -0
  15. data/docs/checks/deprecate_lazysizes.md +61 -0
  16. data/docs/checks/html_parsing_error.md +50 -0
  17. data/docs/checks/liquid_tag.md +2 -2
  18. data/docs/checks/template_length.md +12 -2
  19. data/exe/theme-check-language-server.bat +3 -0
  20. data/exe/theme-check.bat +3 -0
  21. data/lib/theme_check.rb +15 -0
  22. data/lib/theme_check/analyzer.rb +25 -21
  23. data/lib/theme_check/asset_file.rb +3 -15
  24. data/lib/theme_check/bug.rb +3 -1
  25. data/lib/theme_check/check.rb +24 -2
  26. data/lib/theme_check/checks/asset_size_app_block_css.rb +44 -0
  27. data/lib/theme_check/checks/asset_size_app_block_javascript.rb +44 -0
  28. data/lib/theme_check/checks/asset_size_css.rb +11 -74
  29. data/lib/theme_check/checks/asset_size_css_stylesheet_tag.rb +24 -0
  30. data/lib/theme_check/checks/asset_size_javascript.rb +10 -36
  31. data/lib/theme_check/checks/deprecate_bgsizes.rb +14 -0
  32. data/lib/theme_check/checks/deprecate_lazysizes.rb +16 -0
  33. data/lib/theme_check/checks/html_parsing_error.rb +12 -0
  34. data/lib/theme_check/checks/img_lazy_loading.rb +1 -6
  35. data/lib/theme_check/checks/liquid_tag.rb +2 -2
  36. data/lib/theme_check/checks/remote_asset.rb +2 -0
  37. data/lib/theme_check/checks/space_inside_braces.rb +1 -1
  38. data/lib/theme_check/checks/template_length.rb +18 -4
  39. data/lib/theme_check/cli.rb +34 -13
  40. data/lib/theme_check/config.rb +56 -10
  41. data/lib/theme_check/exceptions.rb +29 -27
  42. data/lib/theme_check/html_check.rb +2 -0
  43. data/lib/theme_check/html_visitor.rb +3 -1
  44. data/lib/theme_check/json_file.rb +2 -29
  45. data/lib/theme_check/language_server/constants.rb +8 -0
  46. data/lib/theme_check/language_server/document_link_engine.rb +40 -4
  47. data/lib/theme_check/language_server/handler.rb +1 -1
  48. data/lib/theme_check/language_server/server.rb +13 -2
  49. data/lib/theme_check/liquid_check.rb +0 -12
  50. data/lib/theme_check/parsing_helpers.rb +3 -1
  51. data/lib/theme_check/regex_helpers.rb +17 -0
  52. data/lib/theme_check/tags.rb +62 -8
  53. data/lib/theme_check/template.rb +3 -32
  54. data/lib/theme_check/theme_file.rb +40 -0
  55. data/lib/theme_check/version.rb +1 -1
  56. metadata +22 -3
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
  module ThemeCheck
3
- # Recommends using {% liquid ... %} if 4 or more consecutive {% ... %} are found.
3
+ # Recommends using {% liquid ... %} if 5 or more consecutive {% ... %} are found.
4
4
  class LiquidTag < LiquidCheck
5
5
  severity :suggestion
6
6
  category :liquid
7
7
  doc docs_url(__FILE__)
8
8
 
9
- def initialize(min_consecutive_statements: 4)
9
+ def initialize(min_consecutive_statements: 5)
10
10
  @first_statement = nil
11
11
  @consecutive_statements = 0
12
12
  @min_consecutive_statements = min_consecutive_statements
@@ -9,6 +9,7 @@ module ThemeCheck
9
9
  PROTOCOL = %r{(https?:)?//}
10
10
  ABSOLUTE_PATH = %r{\A/[^/]}im
11
11
  RELATIVE_PATH = %r{\A(?!#{PROTOCOL})[^/\{]}oim
12
+ CDN_ROOT = "https://cdn.shopify.com/"
12
13
 
13
14
  def on_element(node)
14
15
  return unless TAGS.include?(node.name)
@@ -17,6 +18,7 @@ module ThemeCheck
17
18
  return if resource_url.nil? || resource_url.empty?
18
19
 
19
20
  # Ignore if URL is Liquid, taken care of by AssetUrlFilters check
21
+ return if resource_url.start_with?(CDN_ROOT)
20
22
  return if resource_url =~ ABSOLUTE_PATH
21
23
  return if resource_url =~ RELATIVE_PATH
22
24
  return if url_hosted_by_shopify?(resource_url)
@@ -51,7 +51,7 @@ module ThemeCheck
51
51
  end
52
52
 
53
53
  def on_variable(node)
54
- return if @ignore
54
+ return if @ignore || node.markup.empty?
55
55
  if node.markup[0] != " "
56
56
  add_offense("Space missing after '{{'", node: node) do |corrector|
57
57
  corrector.insert_before(node, " ")
@@ -5,9 +5,11 @@ module ThemeCheck
5
5
  category :liquid
6
6
  doc docs_url(__FILE__)
7
7
 
8
- def initialize(max_length: 200, exclude_schema: true)
8
+ def initialize(max_length: 500, exclude_schema: true, exclude_stylesheet: true, exclude_javascript: true)
9
9
  @max_length = max_length
10
10
  @exclude_schema = exclude_schema
11
+ @exclude_stylesheet = exclude_stylesheet
12
+ @exclude_javascript = exclude_javascript
11
13
  end
12
14
 
13
15
  def on_document(_node)
@@ -15,9 +17,15 @@ module ThemeCheck
15
17
  end
16
18
 
17
19
  def on_schema(node)
18
- if @exclude_schema
19
- @excluded_lines += node.value.nodelist.join.count("\n")
20
- end
20
+ exclude_node_lines(node) if @exclude_schema
21
+ end
22
+
23
+ def on_stylesheet(node)
24
+ exclude_node_lines(node) if @exclude_stylesheet
25
+ end
26
+
27
+ def on_javascript(node)
28
+ exclude_node_lines(node) if @exclude_javascript
21
29
  end
22
30
 
23
31
  def after_document(node)
@@ -26,5 +34,11 @@ module ThemeCheck
26
34
  add_offense("Template has too many lines [#{lines}/#{@max_length}]", template: node.template)
27
35
  end
28
36
  end
37
+
38
+ private
39
+
40
+ def exclude_node_lines(node)
41
+ @excluded_lines += node.value.nodelist.join.count("\n")
42
+ end
29
43
  end
30
44
  end
@@ -10,10 +10,11 @@ module ThemeCheck
10
10
  def initialize
11
11
  @path = "."
12
12
  @command = :check
13
- @only_categories = []
13
+ @include_categories = []
14
14
  @exclude_categories = []
15
15
  @auto_correct = false
16
16
  @config_path = nil
17
+ @fail_level = :error
17
18
  end
18
19
 
19
20
  def option_parser(parser = OptionParser.new, help: true)
@@ -25,20 +26,27 @@ module ThemeCheck
25
26
  @option_parser.separator("Basic Options:")
26
27
  @option_parser.on(
27
28
  "-C", "--config PATH",
28
- "Use the config provided, overriding .theme-check.yml if present"
29
+ "Use the config provided, overriding .theme-check.yml if present",
30
+ "Use :theme_app_extension to use default checks for theme app extensions"
29
31
  ) { |path| @config_path = path }
30
32
  @option_parser.on(
31
- "-c", "--category CATEGORY",
32
- "Only run this category of checks"
33
- ) { |category| @only_categories << category.to_sym }
33
+ "-c", "--category CATEGORY", Check::CATEGORIES, "Only run this category of checks",
34
+ "Runs checks matching all categories when specified more than once"
35
+ ) { |category| @include_categories << category.to_sym }
34
36
  @option_parser.on(
35
- "-x", "--exclude-category CATEGORY",
36
- "Exclude this category of checks"
37
+ "-x", "--exclude-category CATEGORY", Check::CATEGORIES, "Exclude this category of checks",
38
+ "Excludes checks matching any category when specified more than once"
37
39
  ) { |category| @exclude_categories << category.to_sym }
38
40
  @option_parser.on(
39
41
  "-a", "--auto-correct",
40
42
  "Automatically fix offenses"
41
43
  ) { @auto_correct = true }
44
+ @option_parser.on(
45
+ "--fail-level SEVERITY", Check::SEVERITIES,
46
+ "Minimum severity (error|suggestion|style) for exit with error code"
47
+ ) do |severity|
48
+ @fail_level = severity.to_sym
49
+ end
42
50
 
43
51
  @option_parser.separator("")
44
52
  @option_parser.separator("Miscellaneous:")
@@ -77,6 +85,8 @@ module ThemeCheck
77
85
 
78
86
  def parse(argv)
79
87
  @path = option_parser.parse(argv).first || "."
88
+ rescue OptionParser::InvalidArgument => e
89
+ abort(e.message)
80
90
  end
81
91
 
82
92
  def run!
@@ -84,13 +94,13 @@ module ThemeCheck
84
94
  @config = if @config_path
85
95
  ThemeCheck::Config.new(
86
96
  root: @path,
87
- configuration: ThemeCheck::Config.load_file(@config_path)
97
+ configuration: ThemeCheck::Config.load_config(@config_path)
88
98
  )
89
99
  else
90
100
  ThemeCheck::Config.from_path(@path)
91
101
  end
92
- @config.only_categories = @only_categories
93
- @config.exclude_categories = @exclude_categories
102
+ @config.include_categories = @include_categories unless @include_categories.empty?
103
+ @config.exclude_categories = @exclude_categories unless @exclude_categories.empty?
94
104
  @config.auto_correct = @auto_correct
95
105
  end
96
106
 
@@ -99,12 +109,16 @@ module ThemeCheck
99
109
 
100
110
  def run
101
111
  run!
112
+ exit(0)
102
113
  rescue Abort => e
103
114
  if e.message.empty?
104
115
  exit(1)
105
116
  else
106
117
  abort(e.message)
107
118
  end
119
+ rescue ThemeCheckError => e
120
+ STDERR.puts(e.message)
121
+ exit(2)
108
122
  end
109
123
 
110
124
  def self.parse_and_run!(argv)
@@ -130,11 +144,16 @@ module ThemeCheck
130
144
  def init
131
145
  dotfile_path = ThemeCheck::Config.find(@path)
132
146
  if dotfile_path.nil?
133
- File.write(File.join(@path, ThemeCheck::Config::DOTFILE), File.read(ThemeCheck::Config::DEFAULT_CONFIG))
147
+ config_name = if @config_path && @config_path[0] == ":"
148
+ "#{@config_path[1..]}.yml"
149
+ else
150
+ "default.yml"
151
+ end
152
+ File.write(File.join(@path, ThemeCheck::Config::DOTFILE), File.read(ThemeCheck::Config.bundled_config_path(config_name)))
134
153
 
135
154
  puts "Writing new #{ThemeCheck::Config::DOTFILE} to #{@path}"
136
155
  else
137
- raise Abort, "#{ThemeCheck::Config::DOTFILE} already exists at #{@path}."
156
+ raise Abort, "#{ThemeCheck::Config::DOTFILE} already exists at #{@path}"
138
157
  end
139
158
  end
140
159
 
@@ -157,7 +176,9 @@ module ThemeCheck
157
176
  analyzer.analyze_theme
158
177
  analyzer.correct_offenses
159
178
  ThemeCheck::Printer.new.print(theme, analyzer.offenses, @config.auto_correct)
160
- raise Abort, "" if analyzer.uncorrectable_offenses.any?
179
+ raise Abort, "" if analyzer.uncorrectable_offenses.any? do |offense|
180
+ offense.check.severity_value <= Check.severity_value(@fail_level)
181
+ end
161
182
  end
162
183
  end
163
184
  end
@@ -3,11 +3,11 @@
3
3
  module ThemeCheck
4
4
  class Config
5
5
  DOTFILE = '.theme-check.yml'
6
- DEFAULT_CONFIG = "#{__dir__}/../../config/default.yml"
6
+ BUNDLED_CONFIGS_DIR = "#{__dir__}/../../config"
7
7
  BOOLEAN = [true, false]
8
8
 
9
9
  attr_reader :root
10
- attr_accessor :only_categories, :exclude_categories, :auto_correct
10
+ attr_accessor :auto_correct
11
11
 
12
12
  class << self
13
13
  attr_reader :last_loaded_config
@@ -42,18 +42,46 @@ module ThemeCheck
42
42
  YAML.load_file(absolute_path)
43
43
  end
44
44
 
45
+ def bundled_config_path(name)
46
+ "#{BUNDLED_CONFIGS_DIR}/#{name}"
47
+ end
48
+
49
+ def load_bundled_config(name)
50
+ load_file(bundled_config_path(name))
51
+ end
52
+
53
+ def load_config(path)
54
+ if path[0] == ":"
55
+ load_bundled_config("#{path[1..]}.yml")
56
+ elsif path.is_a?(Symbol)
57
+ load_bundled_config("#{path}.yml")
58
+ else
59
+ load_file(path)
60
+ end
61
+ end
62
+
45
63
  def default
46
- @default ||= load_file(DEFAULT_CONFIG)
64
+ @default ||= load_config(":default")
47
65
  end
48
66
  end
49
67
 
50
68
  def initialize(root: nil, configuration: nil, should_resolve_requires: true)
51
69
  @configuration = if configuration
70
+ # TODO: Do we need to handle extends here? What base configuration
71
+ # should we validate against once Theme App Extensions has its own
72
+ # checks? :all?
52
73
  validate_configuration(configuration)
53
74
  else
54
75
  {}
55
76
  end
56
- merge_with_default_configuration!(@configuration)
77
+
78
+ # Follow extends
79
+ extends = @configuration["extends"] || ":default"
80
+ while extends
81
+ extended_configuration = self.class.load_config(extends)
82
+ extends = extended_configuration["extends"]
83
+ @configuration = merge_configurations!(@configuration, extended_configuration)
84
+ end
57
85
 
58
86
  @root = if root && @configuration.key?("root")
59
87
  Pathname.new(root).join(@configuration["root"])
@@ -61,8 +89,6 @@ module ThemeCheck
61
89
  Pathname.new(root)
62
90
  end
63
91
 
64
- @only_categories = []
65
- @exclude_categories = []
66
92
  @auto_correct = false
67
93
 
68
94
  resolve_requires if @root && should_resolve_requires
@@ -87,16 +113,18 @@ module ThemeCheck
87
113
  check_class = ThemeCheck.const_get(check_name)
88
114
 
89
115
  next if check_class.categories.any? { |category| exclude_categories.include?(category) }
90
- next if only_categories.any? && check_class.categories.none? { |category| only_categories.include?(category) }
116
+ next if include_categories.any? && !include_categories.all? { |category| check_class.categories.include?(category) }
91
117
 
92
118
  options_for_check = options.transform_keys(&:to_sym)
93
119
  options_for_check.delete(:enabled)
120
+ severity = options_for_check.delete(:severity)
94
121
  ignored_patterns = options_for_check.delete(:ignore) || []
95
122
  check = if options_for_check.empty?
96
123
  check_class.new
97
124
  else
98
125
  check_class.new(**options_for_check)
99
126
  end
127
+ check.severity = severity.to_sym if severity
100
128
  check.ignored_patterns = ignored_patterns
101
129
  check.options = options_for_check
102
130
  check
@@ -107,6 +135,22 @@ module ThemeCheck
107
135
  self["ignore"] || []
108
136
  end
109
137
 
138
+ def include_categories
139
+ self["include_categories"] || []
140
+ end
141
+
142
+ def include_categories=(categories)
143
+ @configuration["include_categories"] = categories
144
+ end
145
+
146
+ def exclude_categories
147
+ self["exclude_categories"] || []
148
+ end
149
+
150
+ def exclude_categories=(categories)
151
+ @configuration["exclude_categories"] = categories
152
+ end
153
+
110
154
  private
111
155
 
112
156
  def check_name?(name)
@@ -133,6 +177,8 @@ module ThemeCheck
133
177
  else
134
178
  warn("bad configuration type for #{name}: expected a Hash, got #{value.inspect}")
135
179
  end
180
+ elsif key == "severity"
181
+ valid_configuration[key] = value
136
182
  elsif default.nil?
137
183
  warn("unknown configuration: #{name}")
138
184
  elsif BOOLEAN.include?(default) && !BOOLEAN.include?(value)
@@ -147,13 +193,13 @@ module ThemeCheck
147
193
  valid_configuration
148
194
  end
149
195
 
150
- def merge_with_default_configuration!(configuration, default_configuration = self.class.default)
151
- default_configuration.each do |key, default|
196
+ def merge_configurations!(configuration, extended_configuration)
197
+ extended_configuration.each do |key, default|
152
198
  value = configuration[key]
153
199
 
154
200
  case value
155
201
  when Hash
156
- merge_with_default_configuration!(value, default)
202
+ merge_configurations!(value, default)
157
203
  when nil
158
204
  configuration[key] = default
159
205
  end
@@ -1,32 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
  require "net/http"
3
3
 
4
- TIMEOUT_EXCEPTIONS = [
5
- Net::ReadTimeout,
6
- Net::OpenTimeout,
7
- Net::WriteTimeout,
8
- Errno::ETIMEDOUT,
9
- Timeout::Error,
10
- ]
4
+ module ThemeCheck
5
+ TIMEOUT_EXCEPTIONS = [
6
+ Net::ReadTimeout,
7
+ Net::OpenTimeout,
8
+ Net::WriteTimeout,
9
+ Errno::ETIMEDOUT,
10
+ Timeout::Error,
11
+ ]
11
12
 
12
- CONNECTION_EXCEPTIONS = [
13
- IOError,
14
- EOFError,
15
- SocketError,
16
- Errno::EINVAL,
17
- Errno::ECONNRESET,
18
- Errno::ECONNABORTED,
19
- Errno::EPIPE,
20
- Errno::ECONNREFUSED,
21
- Errno::EAGAIN,
22
- Errno::EHOSTUNREACH,
23
- Errno::ENETUNREACH,
24
- ]
13
+ CONNECTION_EXCEPTIONS = [
14
+ IOError,
15
+ EOFError,
16
+ SocketError,
17
+ Errno::EINVAL,
18
+ Errno::ECONNRESET,
19
+ Errno::ECONNABORTED,
20
+ Errno::EPIPE,
21
+ Errno::ECONNREFUSED,
22
+ Errno::EAGAIN,
23
+ Errno::EHOSTUNREACH,
24
+ Errno::ENETUNREACH,
25
+ ]
25
26
 
26
- NET_HTTP_EXCEPTIONS = [
27
- Net::HTTPBadResponse,
28
- Net::HTTPHeaderSyntaxError,
29
- Net::ProtocolError,
30
- *TIMEOUT_EXCEPTIONS,
31
- *CONNECTION_EXCEPTIONS,
32
- ]
27
+ NET_HTTP_EXCEPTIONS = [
28
+ Net::HTTPBadResponse,
29
+ Net::HTTPHeaderSyntaxError,
30
+ Net::ProtocolError,
31
+ *TIMEOUT_EXCEPTIONS,
32
+ *CONNECTION_EXCEPTIONS,
33
+ ]
34
+ end
@@ -3,5 +3,7 @@
3
3
  module ThemeCheck
4
4
  class HtmlCheck < Check
5
5
  extend ChecksTracking
6
+ VARIABLE = /#{Liquid::VariableStart}.*?#{Liquid::VariableEnd}/om
7
+ START_OR_END_QUOTE = /(^['"])|(['"]$)/
6
8
  end
7
9
  end
@@ -13,12 +13,14 @@ module ThemeCheck
13
13
  def visit_template(template)
14
14
  doc = parse(template)
15
15
  visit(HtmlNode.new(doc, template))
16
+ rescue ArgumentError => e
17
+ call_checks(:on_parse_error, e, template)
16
18
  end
17
19
 
18
20
  private
19
21
 
20
22
  def parse(template)
21
- Nokogiri::HTML5.fragment(template.source)
23
+ Nokogiri::HTML5.fragment(template.source, max_tree_depth: 400, max_attributes: 400)
22
24
  end
23
25
 
24
26
  def visit(node)
@@ -1,29 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
  require "json"
3
- require "pathname"
4
3
 
5
4
  module ThemeCheck
6
- class JsonFile
5
+ class JsonFile < ThemeFile
7
6
  def initialize(relative_path, storage)
8
- @relative_path = relative_path
9
- @storage = storage
7
+ super
10
8
  @loaded = false
11
9
  @content = nil
12
10
  @parser_error = nil
13
11
  end
14
12
 
15
- def path
16
- @storage.path(@relative_path)
17
- end
18
-
19
- def relative_path
20
- @relative_pathname ||= Pathname.new(@relative_path)
21
- end
22
-
23
- def source
24
- @source ||= @storage.read(@relative_path)
25
- end
26
-
27
13
  def content
28
14
  load!
29
15
  @content
@@ -34,23 +20,10 @@ module ThemeCheck
34
20
  @parser_error
35
21
  end
36
22
 
37
- def name
38
- relative_path.sub_ext('').to_s
39
- end
40
-
41
23
  def json?
42
24
  true
43
25
  end
44
26
 
45
- def liquid?
46
- false
47
- end
48
-
49
- def ==(other)
50
- other.is_a?(JsonFile) && relative_path == other.relative_path
51
- end
52
- alias_method :eql?, :==
53
-
54
27
  private
55
28
 
56
29
  def load!