theme-check 1.2.0 → 1.5.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +3 -3
  3. data/.gitignore +1 -0
  4. data/CHANGELOG.md +47 -0
  5. data/CONTRIBUTING.md +59 -1
  6. data/Gemfile +3 -0
  7. data/bin/theme-check +29 -0
  8. data/bin/theme-check-language-server +29 -0
  9. data/config/default.yml +15 -1
  10. data/config/theme_app_extension.yml +15 -0
  11. data/data/shopify_liquid/objects.yml +1 -0
  12. data/docs/checks/app_block_valid_tags.md +40 -0
  13. data/docs/checks/asset_size_app_block_css.md +1 -1
  14. data/docs/checks/deprecate_lazysizes.md +0 -3
  15. data/docs/checks/deprecated_global_app_block_type.md +65 -0
  16. data/docs/checks/missing_template.md +25 -0
  17. data/docs/checks/pagination_size.md +44 -0
  18. data/docs/checks/template_length.md +1 -1
  19. data/docs/checks/undefined_object.md +5 -0
  20. data/docs/flamegraph.svg +18488 -0
  21. data/lib/theme_check/analyzer.rb +1 -0
  22. data/lib/theme_check/check.rb +2 -2
  23. data/lib/theme_check/checks/app_block_valid_tags.rb +36 -0
  24. data/lib/theme_check/checks/asset_size_css.rb +3 -3
  25. data/lib/theme_check/checks/asset_size_javascript.rb +2 -2
  26. data/lib/theme_check/checks/convert_include_to_render.rb +3 -1
  27. data/lib/theme_check/checks/default_locale.rb +3 -1
  28. data/lib/theme_check/checks/deprecate_bgsizes.rb +1 -1
  29. data/lib/theme_check/checks/deprecate_lazysizes.rb +7 -4
  30. data/lib/theme_check/checks/deprecated_global_app_block_type.rb +57 -0
  31. data/lib/theme_check/checks/img_lazy_loading.rb +1 -1
  32. data/lib/theme_check/checks/img_width_and_height.rb +3 -3
  33. data/lib/theme_check/checks/missing_template.rb +21 -5
  34. data/lib/theme_check/checks/pagination_size.rb +84 -0
  35. data/lib/theme_check/checks/parser_blocking_javascript.rb +1 -1
  36. data/lib/theme_check/checks/remote_asset.rb +3 -3
  37. data/lib/theme_check/checks/space_inside_braces.rb +26 -6
  38. data/lib/theme_check/checks/template_length.rb +1 -1
  39. data/lib/theme_check/checks/undefined_object.rb +1 -1
  40. data/lib/theme_check/checks/valid_html_translation.rb +1 -1
  41. data/lib/theme_check/checks.rb +11 -1
  42. data/lib/theme_check/cli.rb +42 -3
  43. data/lib/theme_check/corrector.rb +9 -0
  44. data/lib/theme_check/file_system_storage.rb +12 -0
  45. data/lib/theme_check/html_check.rb +0 -1
  46. data/lib/theme_check/html_node.rb +37 -16
  47. data/lib/theme_check/html_visitor.rb +17 -3
  48. data/lib/theme_check/json_check.rb +2 -2
  49. data/lib/theme_check/json_file.rb +11 -0
  50. data/lib/theme_check/json_printer.rb +31 -0
  51. data/lib/theme_check/language_server/constants.rb +18 -11
  52. data/lib/theme_check/language_server/document_link_engine.rb +3 -67
  53. data/lib/theme_check/language_server/document_link_provider.rb +71 -0
  54. data/lib/theme_check/language_server/document_link_providers/asset_document_link_provider.rb +11 -0
  55. data/lib/theme_check/language_server/document_link_providers/include_document_link_provider.rb +11 -0
  56. data/lib/theme_check/language_server/document_link_providers/render_document_link_provider.rb +11 -0
  57. data/lib/theme_check/language_server/document_link_providers/section_document_link_provider.rb +11 -0
  58. data/lib/theme_check/language_server/handler.rb +17 -9
  59. data/lib/theme_check/language_server/server.rb +11 -13
  60. data/lib/theme_check/language_server/uri_helper.rb +37 -0
  61. data/lib/theme_check/language_server.rb +6 -0
  62. data/lib/theme_check/node.rb +6 -4
  63. data/lib/theme_check/offense.rb +56 -3
  64. data/lib/theme_check/parsing_helpers.rb +4 -3
  65. data/lib/theme_check/position.rb +98 -14
  66. data/lib/theme_check/printer.rb +9 -5
  67. data/lib/theme_check/regex_helpers.rb +5 -2
  68. data/lib/theme_check/theme.rb +3 -0
  69. data/lib/theme_check/version.rb +1 -1
  70. data/lib/theme_check.rb +1 -0
  71. data/theme-check.gemspec +1 -1
  72. metadata +21 -10
  73. data/bin/liquid-server +0 -4
  74. data/exe/theme-check-language-server.bat +0 -3
  75. data/exe/theme-check.bat +0 -3
@@ -87,6 +87,7 @@ module ThemeCheck
87
87
  if @auto_correct
88
88
  offenses.each(&:correct)
89
89
  @theme.liquid.each(&:write)
90
+ @theme.json.each(&:write)
90
91
  end
91
92
  end
92
93
 
@@ -91,8 +91,8 @@ module ThemeCheck
91
91
  @offenses ||= []
92
92
  end
93
93
 
94
- def add_offense(message, node: nil, template: node&.template, markup: nil, line_number: nil, &block)
95
- offenses << Offense.new(check: self, message: message, template: template, node: node, markup: markup, line_number: line_number, correction: block)
94
+ def add_offense(message, node: nil, template: node&.template, markup: nil, line_number: nil, node_markup_offset: 0, &block)
95
+ offenses << Offense.new(check: self, message: message, template: template, node: node, markup: markup, line_number: line_number, node_markup_offset: node_markup_offset, correction: block)
96
96
  end
97
97
 
98
98
  def severity
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ # Reports errors when invalid tags are used in a Theme App
4
+ # Extension block
5
+ class AppBlockValidTags < LiquidCheck
6
+ severity :error
7
+ category :liquid
8
+ doc docs_url(__FILE__)
9
+
10
+ # Don't allow this check to be disabled with a comment,
11
+ # since we need to be able to enforce this server-side
12
+ can_disable false
13
+
14
+ OFFENSE_MSG = "Theme app extension blocks cannot contain %s tags"
15
+
16
+ def on_javascript(node)
17
+ add_offense(OFFENSE_MSG % 'javascript', node: node)
18
+ end
19
+
20
+ def on_stylesheet(node)
21
+ add_offense(OFFENSE_MSG % 'stylesheet', node: node)
22
+ end
23
+
24
+ def on_include(node)
25
+ add_offense(OFFENSE_MSG % 'include', node: node)
26
+ end
27
+
28
+ def on_layout(node)
29
+ add_offense(OFFENSE_MSG % 'layout', node: node)
30
+ end
31
+
32
+ def on_section(node)
33
+ add_offense(OFFENSE_MSG % 'section', node: node)
34
+ end
35
+ end
36
+ end
@@ -13,12 +13,12 @@ module ThemeCheck
13
13
  end
14
14
 
15
15
  def on_link(node)
16
- return if node.attributes['rel']&.value != "stylesheet"
17
- file_size = href_to_file_size(node.attributes['href']&.value)
16
+ return if node.attributes['rel'] != "stylesheet"
17
+ file_size = href_to_file_size(node.attributes['href'])
18
18
  return if file_size.nil?
19
19
  return if file_size <= threshold_in_bytes
20
20
  add_offense(
21
- "CSS on every page load exceeding compressed size threshold (#{threshold_in_bytes} Bytes).",
21
+ "CSS on every page load exceeding compressed size threshold (#{threshold_in_bytes} Bytes)",
22
22
  node: node
23
23
  )
24
24
  end
@@ -16,7 +16,7 @@ module ThemeCheck
16
16
  end
17
17
 
18
18
  def on_script(node)
19
- file_size = src_to_file_size(node.attributes['src']&.value)
19
+ file_size = src_to_file_size(node.attributes['src'])
20
20
  return if file_size.nil?
21
21
  return if file_size <= threshold_in_bytes
22
22
  add_offense(
@@ -28,7 +28,7 @@ module ThemeCheck
28
28
  def src_to_file_size(src)
29
29
  # We're kind of intentionally only looking at {{ 'asset' | asset_url }} or full urls in here.
30
30
  # More complicated liquid statements are not in scope.
31
- if src =~ /^#{VARIABLE}$/o && src =~ /asset_url/ && src =~ Liquid::QuotedString
31
+ if src =~ /^#{LIQUID_VARIABLE}$/o && src =~ /asset_url/ && src =~ Liquid::QuotedString
32
32
  asset_id = Regexp.last_match(0).gsub(START_OR_END_QUOTE, "")
33
33
  asset = @theme.assets.find { |a| a.name.end_with?("/" + asset_id) }
34
34
  return if asset.nil?
@@ -7,7 +7,9 @@ module ThemeCheck
7
7
  doc docs_url(__FILE__)
8
8
 
9
9
  def on_include(node)
10
- add_offense("`include` is deprecated - convert it to `render`", node: node)
10
+ add_offense("`include` is deprecated - convert it to `render`", node: node) do |corrector|
11
+ corrector.replace(node, "render \'#{node.value.template_name_expr}\' ")
12
+ end
11
13
  end
12
14
  end
13
15
  end
@@ -7,7 +7,9 @@ module ThemeCheck
7
7
 
8
8
  def on_end
9
9
  return if @theme.default_locale_json
10
- add_offense("Default translation file not found (for example locales/en.default.json)")
10
+ add_offense("Default translation file not found (for example locales/en.default.json)") do |corrector|
11
+ corrector.create_default_locale_json(@theme)
12
+ end
11
13
  end
12
14
  end
13
15
  end
@@ -6,7 +6,7 @@ module ThemeCheck
6
6
  doc docs_url(__FILE__)
7
7
 
8
8
  def on_div(node)
9
- class_list = node.attributes["class"]&.value&.split(" ")
9
+ class_list = node.attributes["class"]&.split(" ")
10
10
  add_offense("Use the native loading=\"lazy\" attribute instead of lazysizes", node: node) if class_list&.include?("lazyload")
11
11
  add_offense("Use the CSS imageset attribute instead of data-bgset", node: node) if node.attributes["data-bgset"]
12
12
  end
@@ -6,11 +6,14 @@ module ThemeCheck
6
6
  doc docs_url(__FILE__)
7
7
 
8
8
  def on_img(node)
9
- class_list = node.attributes["class"]&.value&.split(" ")
9
+ class_list = node.attributes["class"]&.split(" ")
10
+ has_loading_lazy = node.attributes["loading"] == "lazy"
11
+ has_native_source = node.attributes["src"] || node.attributes["srcset"]
12
+ return if has_native_source && has_loading_lazy
13
+ has_lazysize_source = node.attributes["data-srcset"] || node.attributes["data-src"]
14
+ has_lazysize_class = class_list&.include?("lazyload")
15
+ return unless has_lazysize_class && has_lazysize_source
10
16
  add_offense("Use the native loading=\"lazy\" attribute instead of lazysizes", node: node) if class_list&.include?("lazyload")
11
- add_offense("Use the native srcset attribute instead of data-srcset", node: node) if node.attributes["data-srcset"]
12
- add_offense("Use the native sizes attribute instead of data-sizes", node: node) if node.attributes["data-sizes"]
13
- add_offense("Do not set the data-sizes attribute to auto", node: node) if node.attributes["data-sizes"]&.value == "auto"
14
17
  end
15
18
  end
16
19
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class DeprecatedGlobalAppBlockType < LiquidCheck
4
+ severity :error
5
+ category :liquid
6
+ doc docs_url(__FILE__)
7
+
8
+ INVALID_GLOBAL_APP_BLOCK_TYPE = "@global"
9
+ VALID_GLOBAL_APP_BLOCK_TYPE = "@app"
10
+
11
+ def on_schema(node)
12
+ schema = JSON.parse(node.value.nodelist.join)
13
+
14
+ if block_types_from(schema).include?(INVALID_GLOBAL_APP_BLOCK_TYPE)
15
+ add_offense(
16
+ "Deprecated '#{INVALID_GLOBAL_APP_BLOCK_TYPE}' block type defined in the schema, use '#{VALID_GLOBAL_APP_BLOCK_TYPE}' block type instead.",
17
+ node: node
18
+ )
19
+ end
20
+ rescue JSON::ParserError
21
+ # Ignored, handled in ValidSchema.
22
+ end
23
+
24
+ def on_case(node)
25
+ if node.value == INVALID_GLOBAL_APP_BLOCK_TYPE
26
+ report_offense(node)
27
+ end
28
+ end
29
+
30
+ def on_condition(node)
31
+ if node.value.right == INVALID_GLOBAL_APP_BLOCK_TYPE || node.value.left == INVALID_GLOBAL_APP_BLOCK_TYPE
32
+ report_offense(node)
33
+ end
34
+ end
35
+
36
+ def on_variable(node)
37
+ if node.value.name == INVALID_GLOBAL_APP_BLOCK_TYPE
38
+ report_offense(node)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def report_offense(node)
45
+ add_offense(
46
+ "Deprecated '#{INVALID_GLOBAL_APP_BLOCK_TYPE}' block type, use '#{VALID_GLOBAL_APP_BLOCK_TYPE}' block type instead.",
47
+ node: node
48
+ )
49
+ end
50
+
51
+ def block_types_from(schema)
52
+ schema.fetch("blocks", []).map do |block|
53
+ block.fetch("type", "")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -8,7 +8,7 @@ module ThemeCheck
8
8
  ACCEPTED_LOADING_VALUES = %w[lazy eager]
9
9
 
10
10
  def on_img(node)
11
- loading = node.attributes["loading"]&.value&.downcase
11
+ loading = node.attributes["loading"]&.downcase
12
12
  return if ACCEPTED_LOADING_VALUES.include?(loading)
13
13
  if loading == "auto"
14
14
  add_offense("Prefer loading=\"lazy\" to defer loading of images", node: node)
@@ -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,84 @@
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
+ ##
40
+ # Section settings look like:
41
+ # #<Liquid::VariableLookup:0x00007fd699c50c48 @name="section", @lookups=["settings", "products_per_page"], @command_flags=0>
42
+ def size_is_a_section_setting?(size)
43
+ size.is_a?(Liquid::VariableLookup) &&
44
+ size.name == 'section' &&
45
+ size.lookups.first == 'settings'
46
+ end
47
+
48
+ ##
49
+ # We'll work with either an explicit value, or the default value of the section setting.
50
+ def get_value(size)
51
+ return size if size.is_a?(Numeric)
52
+ return get_setting_default_value(size) if size_is_a_section_setting?(size)
53
+ end
54
+
55
+ def after_document(_node)
56
+ @paginations.each_pair do |size, nodes|
57
+ # Validate presence of default section setting.
58
+ if size_is_a_section_setting?(size) && !get_setting_default_value(size)
59
+ nodes.each { |node| add_offense("Default pagination size should be defined in the section settings", node: node) }
60
+ end
61
+
62
+ # Validate if size is within range.
63
+ next unless (numerical_size = get_value(size))
64
+ if numerical_size > @max_size || numerical_size < @min_size || !numerical_size.is_a?(Integer)
65
+ nodes.each { |node| add_offense("Pagination size must be a positive integer between #{@min_size} and #{@max_size}", node: node) }
66
+ end
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def get_setting_default_value(variable_lookup)
73
+ setting = @schema_settings.select { |s| s['id'] == variable_lookup.lookups.last }
74
+
75
+ # Setting does not exist.
76
+ return nil if setting.empty?
77
+
78
+ default_value = setting.last['default'].to_i
79
+ return nil if default_value == 0
80
+
81
+ default_value
82
+ end
83
+ end
84
+ 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
@@ -23,9 +23,9 @@ module ThemeCheck
23
23
  return if resource_url =~ RELATIVE_PATH
24
24
  return if url_hosted_by_shopify?(resource_url)
25
25
 
26
- # Ignore non-stylesheet rel tags
26
+ # Ignore non-stylesheet link tags
27
27
  rel = node.attributes["rel"]
28
- return if rel && rel.value != "stylesheet"
28
+ return if node.name == "link" && 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