theme-check 0.10.1 → 1.2.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.
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!