theme-check 1.0.0 → 1.4.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +2 -6
  3. data/CHANGELOG.md +50 -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 +28 -1
  10. data/config/nothing.yml +11 -0
  11. data/config/theme_app_extension.yml +168 -0
  12. data/data/shopify_liquid/objects.yml +1 -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/deprecate_lazysizes.md +0 -3
  17. data/docs/checks/missing_template.md +25 -0
  18. data/docs/checks/pagination_size.md +44 -0
  19. data/docs/checks/template_length.md +1 -1
  20. data/docs/checks/undefined_object.md +5 -0
  21. data/lib/theme_check/analyzer.rb +26 -21
  22. data/lib/theme_check/asset_file.rb +3 -15
  23. data/lib/theme_check/bug.rb +3 -1
  24. data/lib/theme_check/check.rb +26 -4
  25. data/lib/theme_check/checks/app_block_valid_tags.rb +36 -0
  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 +3 -3
  29. data/lib/theme_check/checks/asset_size_javascript.rb +2 -2
  30. data/lib/theme_check/checks/convert_include_to_render.rb +3 -1
  31. data/lib/theme_check/checks/default_locale.rb +3 -1
  32. data/lib/theme_check/checks/deprecate_bgsizes.rb +1 -1
  33. data/lib/theme_check/checks/deprecate_lazysizes.rb +7 -4
  34. data/lib/theme_check/checks/img_lazy_loading.rb +1 -1
  35. data/lib/theme_check/checks/img_width_and_height.rb +3 -3
  36. data/lib/theme_check/checks/missing_template.rb +21 -5
  37. data/lib/theme_check/checks/pagination_size.rb +65 -0
  38. data/lib/theme_check/checks/parser_blocking_javascript.rb +1 -1
  39. data/lib/theme_check/checks/remote_asset.rb +3 -3
  40. data/lib/theme_check/checks/space_inside_braces.rb +27 -7
  41. data/lib/theme_check/checks/template_length.rb +1 -1
  42. data/lib/theme_check/checks/undefined_object.rb +1 -1
  43. data/lib/theme_check/checks/valid_html_translation.rb +1 -1
  44. data/lib/theme_check/checks.rb +11 -1
  45. data/lib/theme_check/cli.rb +52 -15
  46. data/lib/theme_check/config.rb +56 -10
  47. data/lib/theme_check/corrector.rb +9 -0
  48. data/lib/theme_check/exceptions.rb +29 -27
  49. data/lib/theme_check/file_system_storage.rb +12 -0
  50. data/lib/theme_check/html_check.rb +0 -1
  51. data/lib/theme_check/html_node.rb +37 -16
  52. data/lib/theme_check/html_visitor.rb +17 -3
  53. data/lib/theme_check/json_check.rb +2 -2
  54. data/lib/theme_check/json_file.rb +11 -27
  55. data/lib/theme_check/json_printer.rb +26 -0
  56. data/lib/theme_check/language_server/constants.rb +21 -6
  57. data/lib/theme_check/language_server/document_link_engine.rb +3 -31
  58. data/lib/theme_check/language_server/document_link_provider.rb +70 -0
  59. data/lib/theme_check/language_server/document_link_providers/asset_document_link_provider.rb +11 -0
  60. data/lib/theme_check/language_server/document_link_providers/include_document_link_provider.rb +11 -0
  61. data/lib/theme_check/language_server/document_link_providers/render_document_link_provider.rb +11 -0
  62. data/lib/theme_check/language_server/document_link_providers/section_document_link_provider.rb +11 -0
  63. data/lib/theme_check/language_server/handler.rb +7 -4
  64. data/lib/theme_check/language_server/server.rb +13 -2
  65. data/lib/theme_check/language_server.rb +5 -0
  66. data/lib/theme_check/node.rb +6 -4
  67. data/lib/theme_check/offense.rb +56 -3
  68. data/lib/theme_check/parsing_helpers.rb +4 -3
  69. data/lib/theme_check/position.rb +98 -14
  70. data/lib/theme_check/regex_helpers.rb +5 -2
  71. data/lib/theme_check/tags.rb +26 -9
  72. data/lib/theme_check/template.rb +3 -32
  73. data/lib/theme_check/theme.rb +3 -0
  74. data/lib/theme_check/theme_file.rb +40 -0
  75. data/lib/theme_check/version.rb +1 -1
  76. data/lib/theme_check.rb +16 -0
  77. data/theme-check.gemspec +1 -1
  78. metadata +24 -6
  79. data/bin/liquid-server +0 -4
@@ -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
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ # Reports errors when too much CSS is being referenced from a Theme App
4
+ # Extension block
5
+ class AssetSizeAppBlockCSS < LiquidCheck
6
+ severity :error
7
+ category :performance
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
+ attr_reader :threshold_in_bytes
15
+
16
+ def initialize(threshold_in_bytes: 100_000)
17
+ @threshold_in_bytes = threshold_in_bytes
18
+ end
19
+
20
+ def on_schema(node)
21
+ schema = JSON.parse(node.value.nodelist.join)
22
+
23
+ if (stylesheet = schema["stylesheet"])
24
+ size = asset_size(stylesheet)
25
+ if size && size > threshold_in_bytes
26
+ add_offense(
27
+ "CSS in Theme App Extension blocks exceeds compressed size threshold (#{threshold_in_bytes} Bytes)",
28
+ node: node
29
+ )
30
+ end
31
+ end
32
+ rescue JSON::ParserError
33
+ # Ignored, handled in ValidSchema.
34
+ end
35
+
36
+ private
37
+
38
+ def asset_size(name)
39
+ asset = @theme["assets/#{name}"]
40
+ return if asset.nil?
41
+ asset.gzipped_size
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ # Reports errors when too much JS is being referenced from a Theme App
4
+ # Extension block
5
+ class AssetSizeAppBlockJavaScript < LiquidCheck
6
+ severity :error
7
+ category :performance
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
+ attr_reader :threshold_in_bytes
15
+
16
+ def initialize(threshold_in_bytes: 10_000)
17
+ @threshold_in_bytes = threshold_in_bytes
18
+ end
19
+
20
+ def on_schema(node)
21
+ schema = JSON.parse(node.value.nodelist.join)
22
+
23
+ if (javascript = schema["javascript"])
24
+ size = asset_size(javascript)
25
+ if size && size > threshold_in_bytes
26
+ add_offense(
27
+ "JavaScript in Theme App Extension blocks exceeds compressed size threshold (#{threshold_in_bytes} Bytes)",
28
+ node: node
29
+ )
30
+ end
31
+ end
32
+ rescue JSON::ParserError
33
+ # Ignored, handled in ValidSchema.
34
+ end
35
+
36
+ private
37
+
38
+ def asset_size(name)
39
+ asset = @theme["assets/#{name}"]
40
+ return if asset.nil?
41
+ asset.gzipped_size
42
+ end
43
+ end
44
+ 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
@@ -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,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
@@ -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
@@ -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