theme-check 0.10.2 → 1.3.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +2 -6
  3. data/CHANGELOG.md +51 -0
  4. data/CONTRIBUTING.md +1 -1
  5. data/README.md +39 -0
  6. data/RELEASING.md +34 -2
  7. data/bin/theme-check +29 -0
  8. data/bin/theme-check-language-server +29 -0
  9. data/config/default.yml +46 -3
  10. data/config/nothing.yml +11 -0
  11. data/config/theme_app_extension.yml +168 -0
  12. data/data/shopify_liquid/objects.yml +2 -0
  13. data/docs/checks/app_block_valid_tags.md +40 -0
  14. data/docs/checks/asset_size_app_block_css.md +52 -0
  15. data/docs/checks/asset_size_app_block_javascript.md +57 -0
  16. data/docs/checks/asset_size_css_stylesheet_tag.md +50 -0
  17. data/docs/checks/deprecate_bgsizes.md +66 -0
  18. data/docs/checks/deprecate_lazysizes.md +61 -0
  19. data/docs/checks/liquid_tag.md +2 -2
  20. data/docs/checks/missing_template.md +25 -0
  21. data/docs/checks/pagination_size.md +44 -0
  22. data/docs/checks/template_length.md +12 -2
  23. data/docs/checks/undefined_object.md +5 -0
  24. data/lib/theme_check/analyzer.rb +25 -21
  25. data/lib/theme_check/asset_file.rb +3 -15
  26. data/lib/theme_check/bug.rb +3 -1
  27. data/lib/theme_check/check.rb +26 -4
  28. data/lib/theme_check/checks/app_block_valid_tags.rb +36 -0
  29. data/lib/theme_check/checks/asset_size_app_block_css.rb +44 -0
  30. data/lib/theme_check/checks/asset_size_app_block_javascript.rb +44 -0
  31. data/lib/theme_check/checks/asset_size_css.rb +11 -74
  32. data/lib/theme_check/checks/asset_size_css_stylesheet_tag.rb +24 -0
  33. data/lib/theme_check/checks/asset_size_javascript.rb +11 -37
  34. data/lib/theme_check/checks/convert_include_to_render.rb +3 -1
  35. data/lib/theme_check/checks/deprecate_bgsizes.rb +14 -0
  36. data/lib/theme_check/checks/deprecate_lazysizes.rb +16 -0
  37. data/lib/theme_check/checks/img_lazy_loading.rb +2 -7
  38. data/lib/theme_check/checks/img_width_and_height.rb +3 -3
  39. data/lib/theme_check/checks/liquid_tag.rb +2 -2
  40. data/lib/theme_check/checks/missing_template.rb +21 -5
  41. data/lib/theme_check/checks/pagination_size.rb +65 -0
  42. data/lib/theme_check/checks/parser_blocking_javascript.rb +1 -1
  43. data/lib/theme_check/checks/remote_asset.rb +4 -2
  44. data/lib/theme_check/checks/space_inside_braces.rb +27 -7
  45. data/lib/theme_check/checks/template_length.rb +18 -4
  46. data/lib/theme_check/checks/undefined_object.rb +1 -1
  47. data/lib/theme_check/checks/valid_html_translation.rb +1 -1
  48. data/lib/theme_check/checks.rb +11 -1
  49. data/lib/theme_check/cli.rb +52 -15
  50. data/lib/theme_check/config.rb +56 -10
  51. data/lib/theme_check/corrector.rb +4 -0
  52. data/lib/theme_check/exceptions.rb +29 -27
  53. data/lib/theme_check/file_system_storage.rb +12 -0
  54. data/lib/theme_check/html_check.rb +1 -0
  55. data/lib/theme_check/html_node.rb +37 -16
  56. data/lib/theme_check/html_visitor.rb +17 -3
  57. data/lib/theme_check/json_check.rb +2 -2
  58. data/lib/theme_check/json_file.rb +2 -29
  59. data/lib/theme_check/json_printer.rb +26 -0
  60. data/lib/theme_check/language_server/constants.rb +8 -0
  61. data/lib/theme_check/language_server/document_link_engine.rb +40 -4
  62. data/lib/theme_check/language_server/handler.rb +6 -2
  63. data/lib/theme_check/language_server/server.rb +13 -2
  64. data/lib/theme_check/liquid_check.rb +0 -12
  65. data/lib/theme_check/node.rb +6 -4
  66. data/lib/theme_check/offense.rb +56 -3
  67. data/lib/theme_check/parsing_helpers.rb +7 -4
  68. data/lib/theme_check/position.rb +98 -14
  69. data/lib/theme_check/regex_helpers.rb +20 -0
  70. data/lib/theme_check/tags.rb +62 -8
  71. data/lib/theme_check/template.rb +3 -32
  72. data/lib/theme_check/theme.rb +2 -0
  73. data/lib/theme_check/theme_file.rb +40 -0
  74. data/lib/theme_check/version.rb +1 -1
  75. data/lib/theme_check.rb +16 -0
  76. data/theme-check.gemspec +1 -1
  77. metadata +26 -7
  78. data/bin/liquid-server +0 -4
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class AssetSizeCSSStylesheetTag < LiquidCheck
4
+ include RegexHelpers
5
+ severity :error
6
+ category :liquid, :performance
7
+ doc docs_url(__FILE__)
8
+
9
+ def initialize(threshold_in_bytes: 100_000)
10
+ @threshold_in_bytes = threshold_in_bytes
11
+ end
12
+
13
+ def on_variable(node)
14
+ used_filters = node.value.filters.map { |name, *_rest| name }
15
+ return unless used_filters.include?("stylesheet_tag")
16
+ file_size = href_to_file_size('{{' + node.markup + '}}')
17
+ return if file_size <= @threshold_in_bytes
18
+ add_offense(
19
+ "CSS on every page load exceeding compressed size threshold (#{@threshold_in_bytes} Bytes).",
20
+ node: node
21
+ )
22
+ end
23
+ end
24
+ end
@@ -3,58 +3,32 @@ module ThemeCheck
3
3
  # Reports errors when trying to use too much JavaScript on page load
4
4
  # Encourages the use of the Import on Interaction pattern [1].
5
5
  # [1]: https://addyosmani.com/blog/import-on-interaction/
6
- class AssetSizeJavaScript < LiquidCheck
6
+ class AssetSizeJavaScript < HtmlCheck
7
7
  include RegexHelpers
8
8
  severity :error
9
- category :performance
9
+ category :html, :performance
10
10
  doc docs_url(__FILE__)
11
11
 
12
- Script = Struct.new(:src, :match)
13
-
14
- SCRIPT_TAG_SRC = %r{
15
- <script
16
- [^>]+ # any non closing tag character
17
- src= # src attribute start
18
- (?<src>#{QUOTED_LIQUID_ATTRIBUTE}) # src attribute value (may contain liquid)
19
- [^>]* # any non closing character till the end
20
- >
21
- }omix
22
-
23
12
  attr_reader :threshold_in_bytes
24
13
 
25
14
  def initialize(threshold_in_bytes: 10000)
26
15
  @threshold_in_bytes = threshold_in_bytes
27
16
  end
28
17
 
29
- def on_document(node)
30
- @node = node
31
- @source = node.template.source
32
- record_offenses
33
- end
34
-
35
- def record_offenses
36
- scripts(@source).each do |script|
37
- file_size = src_to_file_size(script.src)
38
- next if file_size.nil?
39
- next if file_size <= threshold_in_bytes
40
- add_offense(
41
- "JavaScript on every page load exceding compressed size threshold (#{threshold_in_bytes} Bytes), consider using the import on interaction pattern.",
42
- node: @node,
43
- markup: script.src,
44
- line_number: @source[0...script.match.begin(:src)].count("\n") + 1
45
- )
46
- end
47
- end
48
-
49
- def scripts(source)
50
- matches(source, SCRIPT_TAG_SRC)
51
- .map { |m| Script.new(m[:src].gsub(START_OR_END_QUOTE, ""), m) }
18
+ def on_script(node)
19
+ file_size = src_to_file_size(node.attributes['src'])
20
+ return if file_size.nil?
21
+ return if file_size <= threshold_in_bytes
22
+ add_offense(
23
+ "JavaScript on every page load exceeds compressed size threshold (#{threshold_in_bytes} Bytes), consider using the import on interaction pattern.",
24
+ node: node
25
+ )
52
26
  end
53
27
 
54
28
  def src_to_file_size(src)
55
29
  # We're kind of intentionally only looking at {{ 'asset' | asset_url }} or full urls in here.
56
30
  # More complicated liquid statements are not in scope.
57
- if src =~ /^#{VARIABLE}$/o && src =~ /asset_url/ && src =~ Liquid::QuotedString
31
+ if src =~ /^#{LIQUID_VARIABLE}$/o && src =~ /asset_url/ && src =~ Liquid::QuotedString
58
32
  asset_id = Regexp.last_match(0).gsub(START_OR_END_QUOTE, "")
59
33
  asset = @theme.assets.find { |a| a.name.end_with?("/" + asset_id) }
60
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
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class DeprecateBgsizes < HtmlCheck
4
+ severity :suggestion
5
+ category :html, :performance
6
+ doc docs_url(__FILE__)
7
+
8
+ def on_div(node)
9
+ class_list = node.attributes["class"]&.split(" ")
10
+ add_offense("Use the native loading=\"lazy\" attribute instead of lazysizes", node: node) if class_list&.include?("lazyload")
11
+ add_offense("Use the CSS imageset attribute instead of data-bgset", node: node) if node.attributes["data-bgset"]
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class DeprecateLazysizes < HtmlCheck
4
+ severity :suggestion
5
+ category :html, :performance
6
+ doc docs_url(__FILE__)
7
+
8
+ def on_img(node)
9
+ class_list = node.attributes["class"]&.split(" ")
10
+ 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"] == "auto"
14
+ end
15
+ end
16
+ end
@@ -8,14 +8,9 @@ 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
-
14
- class_list = node.attributes["class"]&.value&.split(" ")
15
-
16
- if class_list&.include?("lazyload")
17
- add_offense("Use the native loading=\"lazy\" attribute instead of lazysizes", node: node)
18
- elsif loading == "auto"
13
+ if loading == "auto"
19
14
  add_offense("Prefer loading=\"lazy\" to defer loading of images", node: node)
20
15
  else
21
16
  add_offense("Add a loading=\"lazy\" attribute 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
@@ -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
@@ -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
@@ -9,21 +9,23 @@ 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)
15
16
 
16
- resource_url = node.attributes["src"]&.value || node.attributes["href"]&.value
17
+ resource_url = node.attributes["src"] || node.attributes["href"]
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)
23
25
 
24
26
  # Ignore non-stylesheet rel tags
25
27
  rel = node.attributes["rel"]
26
- return if rel && rel.value != "stylesheet"
28
+ return if rel && rel != "stylesheet"
27
29
 
28
30
  add_offense(
29
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
@@ -51,13 +71,13 @@ module ThemeCheck
51
71
  end
52
72
 
53
73
  def on_variable(node)
54
- return if @ignore
74
+ return if @ignore || node.markup.empty?
55
75
  if node.markup[0] != " "
56
76
  add_offense("Space missing after '{{'", node: node) do |corrector|
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
@@ -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