theme-check 1.2.0 → 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/CONTRIBUTING.md +1 -1
  4. data/bin/theme-check +29 -0
  5. data/bin/theme-check-language-server +29 -0
  6. data/config/default.yml +11 -0
  7. data/config/theme_app_extension.yml +15 -0
  8. data/docs/checks/app_block_valid_tags.md +40 -0
  9. data/docs/checks/asset_size_app_block_css.md +1 -1
  10. data/docs/checks/missing_template.md +25 -0
  11. data/docs/checks/pagination_size.md +44 -0
  12. data/docs/checks/undefined_object.md +5 -0
  13. data/lib/theme_check/check.rb +2 -2
  14. data/lib/theme_check/checks/app_block_valid_tags.rb +36 -0
  15. data/lib/theme_check/checks/asset_size_css.rb +3 -3
  16. data/lib/theme_check/checks/asset_size_javascript.rb +2 -2
  17. data/lib/theme_check/checks/convert_include_to_render.rb +3 -1
  18. data/lib/theme_check/checks/deprecate_bgsizes.rb +1 -1
  19. data/lib/theme_check/checks/deprecate_lazysizes.rb +2 -2
  20. data/lib/theme_check/checks/img_lazy_loading.rb +1 -1
  21. data/lib/theme_check/checks/img_width_and_height.rb +3 -3
  22. data/lib/theme_check/checks/missing_template.rb +21 -5
  23. data/lib/theme_check/checks/pagination_size.rb +65 -0
  24. data/lib/theme_check/checks/parser_blocking_javascript.rb +1 -1
  25. data/lib/theme_check/checks/remote_asset.rb +2 -2
  26. data/lib/theme_check/checks/space_inside_braces.rb +26 -6
  27. data/lib/theme_check/checks/undefined_object.rb +1 -1
  28. data/lib/theme_check/checks/valid_html_translation.rb +1 -1
  29. data/lib/theme_check/checks.rb +11 -1
  30. data/lib/theme_check/cli.rb +18 -2
  31. data/lib/theme_check/corrector.rb +4 -0
  32. data/lib/theme_check/file_system_storage.rb +12 -0
  33. data/lib/theme_check/html_check.rb +0 -1
  34. data/lib/theme_check/html_node.rb +37 -16
  35. data/lib/theme_check/html_visitor.rb +17 -3
  36. data/lib/theme_check/json_check.rb +2 -2
  37. data/lib/theme_check/json_printer.rb +26 -0
  38. data/lib/theme_check/language_server/handler.rb +6 -2
  39. data/lib/theme_check/node.rb +6 -4
  40. data/lib/theme_check/offense.rb +56 -3
  41. data/lib/theme_check/parsing_helpers.rb +4 -3
  42. data/lib/theme_check/position.rb +98 -14
  43. data/lib/theme_check/regex_helpers.rb +5 -2
  44. data/lib/theme_check/theme.rb +2 -0
  45. data/lib/theme_check/version.rb +1 -1
  46. data/lib/theme_check.rb +1 -0
  47. data/theme-check.gemspec +1 -1
  48. metadata +12 -10
  49. data/bin/liquid-server +0 -4
  50. data/exe/theme-check-language-server.bat +0 -3
  51. data/exe/theme-check.bat +0 -3
@@ -9,8 +9,8 @@ module ThemeCheck
9
9
  ENDS_IN_CSS_UNIT = /(cm|mm|in|px|pt|pc|em|ex|ch|rem|vw|vh|vmin|vmax|%)$/i
10
10
 
11
11
  def on_img(node)
12
- width = node.attributes["width"]&.value
13
- height = node.attributes["height"]&.value
12
+ width = node.attributes["width"]
13
+ height = node.attributes["height"]
14
14
 
15
15
  record_units_in_field_offenses("width", width, node: node)
16
16
  record_units_in_field_offenses("height", height, node: node)
@@ -35,7 +35,7 @@ module ThemeCheck
35
35
  return unless value =~ ENDS_IN_CSS_UNIT
36
36
  value_without_units = value.gsub(ENDS_IN_CSS_UNIT, '')
37
37
  add_offense(
38
- "The #{attribute} attribute does not take units. Replace with \"#{value_without_units}\".",
38
+ "The #{attribute} attribute does not take units. Replace with \"#{value_without_units}\"",
39
39
  node: node,
40
40
  )
41
41
  end
@@ -7,20 +7,36 @@ module ThemeCheck
7
7
  doc docs_url(__FILE__)
8
8
  single_file false
9
9
 
10
+ def initialize(ignore_missing: [])
11
+ @ignore_missing = ignore_missing
12
+ end
13
+
10
14
  def on_include(node)
11
15
  template = node.value.template_name_expr
12
16
  if template.is_a?(String)
13
- unless theme["snippets/#{template}"]
14
- add_offense("'snippets/#{template}.liquid' is not found", node: node)
15
- end
17
+ add_missing_offense("snippets/#{template}", node: node)
16
18
  end
17
19
  end
20
+
18
21
  alias_method :on_render, :on_include
19
22
 
20
23
  def on_section(node)
21
24
  template = node.value.section_name
22
- unless theme["sections/#{template}"]
23
- add_offense("'sections/#{template}.liquid' is not found", node: node)
25
+ add_missing_offense("sections/#{template}", node: node)
26
+ end
27
+
28
+ private
29
+
30
+ def ignore?(path)
31
+ @ignore_missing.any? { |pattern| File.fnmatch?(pattern, path) }
32
+ end
33
+
34
+ def add_missing_offense(name, node:)
35
+ path = "#{name}.liquid"
36
+ unless ignore?(path) || theme[name]
37
+ add_offense("'#{path}' is not found", node: node) do |corrector|
38
+ corrector.create(@theme, "#{name}.liquid", "")
39
+ end
24
40
  end
25
41
  end
26
42
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class PaginationSize < LiquidCheck
4
+ severity :suggestion
5
+ categories :performance
6
+ doc docs_url(__FILE__)
7
+
8
+ attr_reader :min_size
9
+ attr_reader :max_size
10
+
11
+ def initialize(min_size: 1, max_size: 50)
12
+ @min_size = min_size
13
+ @max_size = max_size
14
+ end
15
+
16
+ def on_document(_node)
17
+ @paginations = {}
18
+ @schema_settings = {}
19
+ end
20
+
21
+ def on_paginate(node)
22
+ size = node.value.page_size
23
+ unless @paginations.key?(size)
24
+ @paginations[size] = []
25
+ end
26
+ @paginations[size].push(node)
27
+ end
28
+
29
+ def on_schema(node)
30
+ schema = JSON.parse(node.value.nodelist.join)
31
+
32
+ if (settings = schema["settings"])
33
+ @schema_settings = settings
34
+ end
35
+ rescue JSON::ParserError
36
+ # Ignored, handled in ValidSchema.
37
+ end
38
+
39
+ def after_document(_node)
40
+ @paginations.each_pair do |size, nodes|
41
+ numerical_size = if size.is_a?(Numeric)
42
+ size
43
+ else
44
+ get_setting_default_value(size.lookups.last)
45
+ end
46
+ if numerical_size.nil?
47
+ nodes.each { |node| add_offense("Default pagination size should be defined in the section settings", node: node) }
48
+ elsif numerical_size > @max_size || numerical_size < @min_size || !numerical_size.is_a?(Integer)
49
+ nodes.each { |node| add_offense("Pagination size must be a positive integer between #{@min_size} and #{@max_size}", node: node) }
50
+ end
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def get_setting_default_value(setting_id)
57
+ setting = @schema_settings.select { |s| s['id'] == setting_id }
58
+ unless setting.empty?
59
+ return setting.last['default']
60
+ end
61
+ # Setting does not exist
62
+ nil
63
+ end
64
+ end
65
+ end
@@ -8,7 +8,7 @@ module ThemeCheck
8
8
 
9
9
  def on_script(node)
10
10
  return unless node.attributes["src"]
11
- return if node.attributes["defer"] || node.attributes["async"] || node.attributes["type"]&.value == "module"
11
+ return if node.attributes["defer"] || node.attributes["async"] || node.attributes["type"] == "module"
12
12
 
13
13
  add_offense("Missing async or defer attribute on script tag", node: node)
14
14
  end
@@ -14,7 +14,7 @@ module ThemeCheck
14
14
  def on_element(node)
15
15
  return unless TAGS.include?(node.name)
16
16
 
17
- resource_url = node.attributes["src"]&.value || node.attributes["href"]&.value
17
+ resource_url = node.attributes["src"] || node.attributes["href"]
18
18
  return if resource_url.nil? || resource_url.empty?
19
19
 
20
20
  # Ignore if URL is Liquid, taken care of by AssetUrlFilters check
@@ -25,7 +25,7 @@ module ThemeCheck
25
25
 
26
26
  # Ignore non-stylesheet rel tags
27
27
  rel = node.attributes["rel"]
28
- return if rel && rel.value != "stylesheet"
28
+ return if rel && rel != "stylesheet"
29
29
 
30
30
  add_offense(
31
31
  "Asset should be served by the Shopify CDN for better performance.",
@@ -14,18 +14,38 @@ module ThemeCheck
14
14
  return unless node.markup
15
15
  return if :assign == node.type_name
16
16
 
17
- outside_of_strings(node.markup) do |chunk|
17
+ outside_of_strings(node.markup) do |chunk, chunk_start|
18
18
  chunk.scan(/([,:|]|==|<>|<=|>=|<|>|!=) +/) do |_match|
19
- add_offense("Too many spaces after '#{Regexp.last_match(1)}'", node: node, markup: Regexp.last_match(0))
19
+ add_offense(
20
+ "Too many spaces after '#{Regexp.last_match(1)}'",
21
+ node: node,
22
+ markup: Regexp.last_match(0),
23
+ node_markup_offset: chunk_start + Regexp.last_match.begin(0)
24
+ )
20
25
  end
21
26
  chunk.scan(/([,:|]|==|<>|<=|>=|<\b|>\b|!=)(\S|\z)/) do |_match|
22
- add_offense("Space missing after '#{Regexp.last_match(1)}'", node: node, markup: Regexp.last_match(0))
27
+ add_offense(
28
+ "Space missing after '#{Regexp.last_match(1)}'",
29
+ node: node,
30
+ markup: Regexp.last_match(0),
31
+ node_markup_offset: chunk_start + Regexp.last_match.begin(0),
32
+ )
23
33
  end
24
34
  chunk.scan(/ (\||==|<>|<=|>=|<|>|!=)+/) do |_match|
25
- add_offense("Too many spaces before '#{Regexp.last_match(1)}'", node: node, markup: Regexp.last_match(0))
35
+ add_offense(
36
+ "Too many spaces before '#{Regexp.last_match(1)}'",
37
+ node: node,
38
+ markup: Regexp.last_match(0),
39
+ node_markup_offset: chunk_start + Regexp.last_match.begin(0)
40
+ )
26
41
  end
27
42
  chunk.scan(/(\A|\S)(?<match>\||==|<>|<=|>=|<|\b>|!=)/) do |_match|
28
- add_offense("Space missing before '#{Regexp.last_match(1)}'", node: node, markup: Regexp.last_match(0))
43
+ add_offense(
44
+ "Space missing before '#{Regexp.last_match(1)}'",
45
+ node: node,
46
+ markup: Regexp.last_match(0),
47
+ node_markup_offset: chunk_start + Regexp.last_match.begin(0)
48
+ )
29
49
  end
30
50
  end
31
51
  end
@@ -57,7 +77,7 @@ module ThemeCheck
57
77
  corrector.insert_before(node, " ")
58
78
  end
59
79
  end
60
- if node.markup[-1] != " "
80
+ if node.markup[-1] != " " && node.markup[-1] != "\n"
61
81
  add_offense("Space missing before '}}'", node: node) do |corrector|
62
82
  corrector.insert_after(node, " ")
63
83
  end
@@ -55,7 +55,7 @@ module ThemeCheck
55
55
  end
56
56
  end
57
57
 
58
- def initialize(exclude_snippets: false)
58
+ def initialize(exclude_snippets: true)
59
59
  @exclude_snippets = exclude_snippets
60
60
  @files = {}
61
61
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'nokogumbo'
3
+ require 'nokogiri'
4
4
 
5
5
  module ThemeCheck
6
6
  class ValidHTMLTranslation < JsonCheck
@@ -29,8 +29,18 @@ module ThemeCheck
29
29
  def call_check_method(check, method, *args)
30
30
  return unless check.respond_to?(method) && !check.ignored?
31
31
 
32
- Timeout.timeout(CHECK_METHOD_TIMEOUT) do
32
+ # If you want to use binding.pry in unit tests, define the
33
+ # THEME_CHECK_DEBUG environment variable. e.g.
34
+ #
35
+ # $ export THEME_CHECK_DEBUG=true
36
+ # $ bundle exec rake tests:in_memory
37
+ #
38
+ if ENV['THEME_CHECK_DEBUG']
33
39
  check.send(method, *args)
40
+ else
41
+ Timeout.timeout(CHECK_METHOD_TIMEOUT) do
42
+ check.send(method, *args)
43
+ end
34
44
  end
35
45
  rescue Liquid::Error
36
46
  # Pass-through Liquid errors
@@ -5,6 +5,8 @@ module ThemeCheck
5
5
  class Cli
6
6
  class Abort < StandardError; end
7
7
 
8
+ FORMATS = [:text, :json]
9
+
8
10
  attr_accessor :path
9
11
 
10
12
  def initialize
@@ -15,6 +17,7 @@ module ThemeCheck
15
17
  @auto_correct = false
16
18
  @config_path = nil
17
19
  @fail_level = :error
20
+ @format = :text
18
21
  end
19
22
 
20
23
  def option_parser(parser = OptionParser.new, help: true)
@@ -29,6 +32,10 @@ module ThemeCheck
29
32
  "Use the config provided, overriding .theme-check.yml if present",
30
33
  "Use :theme_app_extension to use default checks for theme app extensions"
31
34
  ) { |path| @config_path = path }
35
+ @option_parser.on(
36
+ "-o", "--output FORMAT", FORMATS,
37
+ "The output format to use. (text|json, default: text)"
38
+ ) { |format| @format = format.to_sym }
32
39
  @option_parser.on(
33
40
  "-c", "--category CATEGORY", Check::CATEGORIES, "Only run this category of checks",
34
41
  "Runs checks matching all categories when specified more than once"
@@ -166,7 +173,7 @@ module ThemeCheck
166
173
  end
167
174
 
168
175
  def check
169
- puts "Checking #{@config.root} ..."
176
+ STDERR.puts "Checking #{@config.root} ..."
170
177
  storage = ThemeCheck::FileSystemStorage.new(@config.root, ignored_patterns: @config.ignored_patterns)
171
178
  theme = ThemeCheck::Theme.new(storage)
172
179
  if theme.all.empty?
@@ -175,10 +182,19 @@ module ThemeCheck
175
182
  analyzer = ThemeCheck::Analyzer.new(theme, @config.enabled_checks, @config.auto_correct)
176
183
  analyzer.analyze_theme
177
184
  analyzer.correct_offenses
178
- ThemeCheck::Printer.new.print(theme, analyzer.offenses, @config.auto_correct)
185
+ output_with_format(theme, analyzer)
179
186
  raise Abort, "" if analyzer.uncorrectable_offenses.any? do |offense|
180
187
  offense.check.severity_value <= Check.severity_value(@fail_level)
181
188
  end
182
189
  end
190
+
191
+ def output_with_format(theme, analyzer)
192
+ case @format
193
+ when :text
194
+ ThemeCheck::Printer.new.print(theme, analyzer.offenses, @config.auto_correct)
195
+ when :json
196
+ ThemeCheck::JsonPrinter.new.print(analyzer.offenses)
197
+ end
198
+ end
183
199
  end
184
200
  end
@@ -27,5 +27,9 @@ module ThemeCheck
27
27
  line.insert(node.range[0], insert_before)
28
28
  line.insert(node.range[1] + 1 + insert_before.length, insert_after)
29
29
  end
30
+
31
+ def create(theme, relative_path, content)
32
+ theme.storage.write(relative_path, content)
33
+ end
30
34
  end
31
35
  end
@@ -20,6 +20,9 @@ module ThemeCheck
20
20
  end
21
21
 
22
22
  def write(relative_path, content)
23
+ reset_memoizers unless file_exists?(relative_path)
24
+
25
+ file(relative_path).dirname.mkpath unless file(relative_path).dirname.directory?
23
26
  file(relative_path).write(content)
24
27
  end
25
28
 
@@ -36,6 +39,15 @@ module ThemeCheck
36
39
 
37
40
  private
38
41
 
42
+ def file_exists?(relative_path)
43
+ !!@files[relative_path]
44
+ end
45
+
46
+ def reset_memoizers
47
+ @file_array = nil
48
+ @directories = nil
49
+ end
50
+
39
51
  def glob(pattern)
40
52
  @root.glob(pattern).reject do |path|
41
53
  relative_path = path.relative_path_from(@root)
@@ -3,7 +3,6 @@
3
3
  module ThemeCheck
4
4
  class HtmlCheck < Check
5
5
  extend ChecksTracking
6
- VARIABLE = /#{Liquid::VariableStart}.*?#{Liquid::VariableEnd}/om
7
6
  START_OR_END_QUOTE = /(^['"])|(['"]$)/
8
7
  end
9
8
  end
@@ -4,13 +4,14 @@ require "forwardable"
4
4
  module ThemeCheck
5
5
  class HtmlNode
6
6
  extend Forwardable
7
- attr_reader :template
7
+ include RegexHelpers
8
+ attr_reader :template, :parent
8
9
 
9
- def_delegators :@value, :content, :attributes
10
-
11
- def initialize(value, template)
10
+ def initialize(value, template, placeholder_values = [], parent = nil)
12
11
  @value = value
13
12
  @template = template
13
+ @placeholder_values = placeholder_values
14
+ @parent = parent
14
15
  end
15
16
 
16
17
  def literal?
@@ -22,35 +23,55 @@ module ThemeCheck
22
23
  end
23
24
 
24
25
  def children
25
- @value.children.map { |child| HtmlNode.new(child, template) }
26
+ @children ||= @value
27
+ .children
28
+ .map { |child| HtmlNode.new(child, template, @placeholder_values, self) }
26
29
  end
27
30
 
28
- def parent
29
- HtmlNode.new(@value.parent, template)
31
+ def attributes
32
+ @attributes ||= @value.attributes
33
+ .map { |k, v| [replace_placeholders(k), replace_placeholders(v.value)] }
34
+ .to_h
30
35
  end
31
36
 
32
- def name
33
- if @value.name == "#document-fragment"
34
- "document"
35
- else
36
- @value.name
37
- end
37
+ def content
38
+ @content ||= replace_placeholders(@value.content)
38
39
  end
39
40
 
41
+ # @value is not forwarded because we _need_ to replace the
42
+ # placeholders for the HtmlNode to make sense.
40
43
  def value
41
44
  if literal?
42
- @value.content
45
+ content
43
46
  else
44
- @value
47
+ markup
48
+ end
49
+ end
50
+
51
+ def name
52
+ if @value.name == "#document-fragment"
53
+ "document"
54
+ else
55
+ @value.name
45
56
  end
46
57
  end
47
58
 
48
59
  def markup
49
- @value.to_html
60
+ @markup ||= replace_placeholders(@value.to_html)
50
61
  end
51
62
 
52
63
  def line_number
53
64
  @value.line
54
65
  end
66
+
67
+ private
68
+
69
+ def replace_placeholders(string)
70
+ # Replace all {%#{i}####%} with the actual content.
71
+ string.gsub(LIQUID_TAG) do |match|
72
+ key = /\d+/.match(match)[0]
73
+ @placeholder_values[key.to_i]
74
+ end
75
+ end
55
76
  end
56
77
  end
@@ -1,18 +1,20 @@
1
1
  # frozen_string_literal: true
2
- require "nokogumbo"
2
+ require "nokogiri"
3
3
  require "forwardable"
4
4
 
5
5
  module ThemeCheck
6
6
  class HtmlVisitor
7
+ include RegexHelpers
7
8
  attr_reader :checks
8
9
 
9
10
  def initialize(checks)
10
11
  @checks = checks
12
+ @placeholder_values = []
11
13
  end
12
14
 
13
15
  def visit_template(template)
14
16
  doc = parse(template)
15
- visit(HtmlNode.new(doc, template))
17
+ visit(HtmlNode.new(doc, template, @placeholder_values))
16
18
  rescue ArgumentError => e
17
19
  call_checks(:on_parse_error, e, template)
18
20
  end
@@ -20,7 +22,19 @@ module ThemeCheck
20
22
  private
21
23
 
22
24
  def parse(template)
23
- Nokogiri::HTML5.fragment(template.source, max_tree_depth: 400, max_attributes: 400)
25
+ parseable_source = +template.source.clone
26
+
27
+ # Replace all liquid tags with {%#{i}######%} to prevent the HTML
28
+ # parser from freaking out. We transparently replace those placeholders in
29
+ # HtmlNode.
30
+ matches(parseable_source, LIQUID_TAG_OR_VARIABLE).each do |m|
31
+ value = m[0]
32
+ @placeholder_values.push(value)
33
+ key = (@placeholder_values.size - 1).to_s
34
+ parseable_source[m.begin(0)...m.end(0)] = "{%#{key.ljust(m.end(0) - m.begin(0) - 4, '#')}%}"
35
+ end
36
+
37
+ Nokogiri::HTML5.fragment(parseable_source, max_tree_depth: 400, max_attributes: 400)
24
38
  end
25
39
 
26
40
  def visit(node)