theme-check 0.8.1 → 0.10.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +3 -0
  3. data/CHANGELOG.md +39 -0
  4. data/CONTRIBUTING.md +2 -1
  5. data/README.md +4 -1
  6. data/RELEASING.md +5 -3
  7. data/config/default.yml +46 -1
  8. data/data/shopify_liquid/tags.yml +3 -0
  9. data/docs/checks/asset_url_filters.md +56 -0
  10. data/docs/checks/content_for_header_modification.md +42 -0
  11. data/docs/checks/img_lazy_loading.md +61 -0
  12. data/docs/checks/nested_snippet.md +1 -1
  13. data/docs/checks/parser_blocking_script_tag.md +53 -0
  14. data/docs/checks/space_inside_braces.md +22 -0
  15. data/exe/theme-check-language-server +1 -2
  16. data/lib/theme_check.rb +9 -1
  17. data/lib/theme_check/analyzer.rb +72 -16
  18. data/lib/theme_check/bug.rb +20 -0
  19. data/lib/theme_check/check.rb +31 -6
  20. data/lib/theme_check/checks.rb +49 -4
  21. data/lib/theme_check/checks/asset_url_filters.rb +46 -0
  22. data/lib/theme_check/checks/content_for_header_modification.rb +41 -0
  23. data/lib/theme_check/checks/img_lazy_loading.rb +25 -0
  24. data/lib/theme_check/checks/img_width_and_height.rb +18 -49
  25. data/lib/theme_check/checks/missing_template.rb +1 -0
  26. data/lib/theme_check/checks/nested_snippet.rb +1 -1
  27. data/lib/theme_check/checks/parser_blocking_javascript.rb +6 -38
  28. data/lib/theme_check/checks/parser_blocking_script_tag.rb +20 -0
  29. data/lib/theme_check/checks/remote_asset.rb +21 -79
  30. data/lib/theme_check/checks/space_inside_braces.rb +5 -5
  31. data/lib/theme_check/checks/template_length.rb +3 -0
  32. data/lib/theme_check/checks/valid_html_translation.rb +1 -0
  33. data/lib/theme_check/config.rb +2 -0
  34. data/lib/theme_check/disabled_check.rb +6 -4
  35. data/lib/theme_check/disabled_checks.rb +25 -9
  36. data/lib/theme_check/html_check.rb +7 -0
  37. data/lib/theme_check/html_node.rb +56 -0
  38. data/lib/theme_check/html_visitor.rb +38 -0
  39. data/lib/theme_check/json_file.rb +8 -0
  40. data/lib/theme_check/language_server.rb +2 -0
  41. data/lib/theme_check/language_server/completion_providers/filter_completion_provider.rb +1 -0
  42. data/lib/theme_check/language_server/completion_providers/object_completion_provider.rb +10 -8
  43. data/lib/theme_check/language_server/diagnostics_tracker.rb +64 -0
  44. data/lib/theme_check/language_server/handler.rb +31 -26
  45. data/lib/theme_check/language_server/server.rb +1 -1
  46. data/lib/theme_check/language_server/variable_lookup_finder.rb +295 -0
  47. data/lib/theme_check/liquid_check.rb +1 -4
  48. data/lib/theme_check/offense.rb +18 -0
  49. data/lib/theme_check/shopify_liquid/tag.rb +13 -0
  50. data/lib/theme_check/template.rb +8 -0
  51. data/lib/theme_check/theme.rb +7 -2
  52. data/lib/theme_check/version.rb +1 -1
  53. data/lib/theme_check/visitor.rb +2 -11
  54. metadata +17 -3
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class ImgLazyLoading < HtmlCheck
4
+ severity :suggestion
5
+ categories :html, :performance
6
+ doc docs_url(__FILE__)
7
+
8
+ ACCEPTED_LOADING_VALUES = %w[lazy eager]
9
+
10
+ def on_img(node)
11
+ loading = node.attributes["loading"]&.value&.downcase
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"
19
+ add_offense("Prefer loading=\"lazy\" to defer loading of images", node: node)
20
+ else
21
+ add_offense("Add a loading=\"lazy\" attribute to defer loading of images", node: node)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -1,41 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
  module ThemeCheck
3
3
  # Reports errors when trying to use parser-blocking script tags
4
- class ImgWidthAndHeight < LiquidCheck
5
- include RegexHelpers
4
+ class ImgWidthAndHeight < HtmlCheck
6
5
  severity :error
7
- categories :liquid, :performance
6
+ categories :html, :performance
8
7
  doc docs_url(__FILE__)
9
8
 
10
- # Not implemented with lookbehinds and lookaheads because performance was shit!
11
- IMG_TAG = %r{<img#{HTML_ATTRIBUTES}/?>}oxim
12
- SRC_ATTRIBUTE = /\s(src)=(#{QUOTED_LIQUID_ATTRIBUTE})/oxim
13
- WIDTH_ATTRIBUTE = /\s(width)=(#{QUOTED_LIQUID_ATTRIBUTE})/oxim
14
- HEIGHT_ATTRIBUTE = /\s(height)=(#{QUOTED_LIQUID_ATTRIBUTE})/oxim
15
-
16
- FIELDS = [WIDTH_ATTRIBUTE, HEIGHT_ATTRIBUTE]
17
9
  ENDS_IN_CSS_UNIT = /(cm|mm|in|px|pt|pc|em|ex|ch|rem|vw|vh|vmin|vmax|%)$/i
18
10
 
19
- def on_document(node)
20
- @source = node.template.source
21
- @node = node
22
- record_offenses
23
- end
11
+ def on_img(node)
12
+ width = node.attributes["width"]&.value
13
+ height = node.attributes["height"]&.value
24
14
 
25
- private
15
+ record_units_in_field_offenses("width", width, node: node)
16
+ record_units_in_field_offenses("height", height, node: node)
26
17
 
27
- def record_offenses
28
- matches(@source, IMG_TAG).each do |img_match|
29
- next unless img_match[0] =~ SRC_ATTRIBUTE
30
- record_missing_field_offenses(img_match)
31
- record_units_in_field_offenses(img_match)
32
- end
33
- end
34
-
35
- def record_missing_field_offenses(img_match)
36
- width = WIDTH_ATTRIBUTE.match(img_match[0])
37
- height = HEIGHT_ATTRIBUTE.match(img_match[0])
38
- return if width && height
18
+ return if node.attributes["src"].nil? || (width && height)
39
19
  missing_width = width.nil?
40
20
  missing_height = height.nil?
41
21
  error_message = if missing_width && missing_height
@@ -46,29 +26,18 @@ module ThemeCheck
46
26
  "Missing height attribute"
47
27
  end
48
28
 
49
- add_offense(
50
- error_message,
51
- node: @node,
52
- markup: img_match[0],
53
- line_number: @source[0...img_match.begin(0)].count("\n") + 1
54
- )
29
+ add_offense(error_message, node: node)
55
30
  end
56
31
 
57
- def record_units_in_field_offenses(img_match)
58
- FIELDS.each do |field|
59
- field_match = field.match(img_match[0])
60
- next if field_match.nil?
61
- value = field_match[2].gsub(START_OR_END_QUOTE, '')
62
- next unless value =~ ENDS_IN_CSS_UNIT
63
- value_without_units = value.gsub(ENDS_IN_CSS_UNIT, '')
64
- start = img_match.begin(0) + field_match.begin(2)
65
- add_offense(
66
- "The #{field_match[1]} attribute does not take units. Replace with \"#{value_without_units}\".",
67
- node: @node,
68
- markup: value,
69
- line_number: @source[0...start].count("\n") + 1
70
- )
71
- end
32
+ private
33
+
34
+ def record_units_in_field_offenses(attribute, value, node:)
35
+ return unless value =~ ENDS_IN_CSS_UNIT
36
+ value_without_units = value.gsub(ENDS_IN_CSS_UNIT, '')
37
+ add_offense(
38
+ "The #{attribute} attribute does not take units. Replace with \"#{value_without_units}\".",
39
+ node: node,
40
+ )
72
41
  end
73
42
  end
74
43
  end
@@ -5,6 +5,7 @@ module ThemeCheck
5
5
  severity :suggestion
6
6
  category :liquid
7
7
  doc docs_url(__FILE__)
8
+ single_file false
8
9
 
9
10
  def on_include(node)
10
11
  template = node.value.template_name_expr
@@ -20,7 +20,7 @@ module ThemeCheck
20
20
  end
21
21
  end
22
22
 
23
- def initialize(max_nesting_level: 2)
23
+ def initialize(max_nesting_level: 3)
24
24
  @max_nesting_level = max_nesting_level
25
25
  @templates = {}
26
26
  end
@@ -1,48 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
  module ThemeCheck
3
3
  # Reports errors when trying to use parser-blocking script tags
4
- class ParserBlockingJavaScript < LiquidCheck
5
- include RegexHelpers
4
+ class ParserBlockingJavaScript < HtmlCheck
6
5
  severity :error
7
- categories :liquid, :performance
6
+ categories :html, :performance
8
7
  doc docs_url(__FILE__)
9
8
 
10
- PARSER_BLOCKING_SCRIPT_TAG = %r{
11
- <script # Find the start of a script tag
12
- (?=[^>]+?src=) # Make sure src= is in the script with a lookahead
13
- (?:(?!defer|async|type=["']module['"]).)*? # Find tags that don't have defer|async|type="module"
14
- /?>
15
- }xim
16
- SCRIPT_TAG_FILTER = /\{\{[^}]+script_tag\s+\}\}/
9
+ def on_script(node)
10
+ return unless node.attributes["src"]
11
+ return if node.attributes["defer"] || node.attributes["async"] || node.attributes["type"]&.value == "module"
17
12
 
18
- def on_document(node)
19
- @source = node.template.source
20
- @node = node
21
- record_offenses
22
- end
23
-
24
- private
25
-
26
- def record_offenses
27
- record_offenses_from_regex(
28
- message: "Missing async or defer attribute on script tag",
29
- regex: PARSER_BLOCKING_SCRIPT_TAG,
30
- )
31
- record_offenses_from_regex(
32
- message: "The script_tag filter is parser-blocking. Use a script tag with the async or defer attribute for better performance",
33
- regex: SCRIPT_TAG_FILTER,
34
- )
35
- end
36
-
37
- def record_offenses_from_regex(regex: nil, message: nil)
38
- matches(@source, regex).each do |match|
39
- add_offense(
40
- message,
41
- node: @node,
42
- markup: match[0],
43
- line_number: @source[0...match.begin(0)].count("\n") + 1
44
- )
45
- end
13
+ add_offense("Missing async or defer attribute on script tag", node: node)
46
14
  end
47
15
  end
48
16
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ # Reports errors when trying to use parser-blocking script tags
4
+ class ParserBlockingScriptTag < LiquidCheck
5
+ severity :error
6
+ categories :liquid, :performance
7
+ doc docs_url(__FILE__)
8
+
9
+ def on_variable(node)
10
+ used_filters = node.value.filters.map { |name, *_rest| name }
11
+ if used_filters.include?("script_tag")
12
+ add_offense(
13
+ "The script_tag filter is parser-blocking. Use a script tag with the async or defer " \
14
+ "attribute for better performance",
15
+ node: node
16
+ )
17
+ end
18
+ end
19
+ end
20
+ end
@@ -1,99 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
  module ThemeCheck
3
- class RemoteAsset < LiquidCheck
4
- include RegexHelpers
3
+ class RemoteAsset < HtmlCheck
5
4
  severity :suggestion
6
- categories :liquid, :performance
5
+ categories :html, :performance
7
6
  doc docs_url(__FILE__)
8
7
 
9
- OFFENSE_MESSAGE = "Asset should be served by the Shopify CDN for better performance."
10
-
11
- HTML_FILTERS = [
12
- 'stylesheet_tag',
13
- 'script_tag',
14
- 'img_tag',
15
- ]
16
- ASSET_URL_FILTERS = [
17
- 'asset_url',
18
- 'asset_img_url',
19
- 'file_img_url',
20
- 'file_url',
21
- 'global_asset_url',
22
- 'img_url',
23
- 'payment_type_img_url',
24
- 'shopify_asset_url',
25
- ]
26
-
27
- RESOURCE_TAG = %r{<(?<tag_name>img|script|link|source)#{HTML_ATTRIBUTES}/?>}oim
28
- RESOURCE_URL = /\s(?:src|href)=(?<resource_url>#{QUOTED_LIQUID_ATTRIBUTE})/oim
29
- ASSET_URL_FILTER = /[\|\s]*(#{ASSET_URL_FILTERS.join('|')})/omi
8
+ TAGS = %w[img script link source]
30
9
  PROTOCOL = %r{(https?:)?//}
31
10
  ABSOLUTE_PATH = %r{\A/[^/]}im
32
11
  RELATIVE_PATH = %r{\A(?!#{PROTOCOL})[^/\{]}oim
33
- REL = /\srel=(?<rel>#{QUOTED_LIQUID_ATTRIBUTE})/oim
34
-
35
- def on_variable(node)
36
- record_variable_offense(node)
37
- end
38
12
 
39
- def on_document(node)
40
- source = node.template.source
41
- record_html_offenses(node, source)
42
- end
13
+ def on_element(node)
14
+ return unless TAGS.include?(node.name)
43
15
 
44
- private
16
+ resource_url = node.attributes["src"]&.value || node.attributes["href"]&.value
17
+ return if resource_url.nil? || resource_url.empty?
45
18
 
46
- def record_variable_offense(variable_node)
47
- # We flag HTML tags with URLs not hosted by Shopify
48
- return if !html_resource_drop?(variable_node) || variable_hosted_by_shopify?(variable_node)
49
- add_offense(OFFENSE_MESSAGE, node: variable_node)
50
- end
19
+ # Ignore if URL is Liquid, taken care of by AssetUrlFilters check
20
+ return if resource_url =~ ABSOLUTE_PATH
21
+ return if resource_url =~ RELATIVE_PATH
22
+ return if url_hosted_by_shopify?(resource_url)
51
23
 
52
- def html_resource_drop?(variable_node)
53
- variable_node.value.filters
54
- .any? { |(filter_name, *_filter_args)| HTML_FILTERS.include?(filter_name) }
55
- end
24
+ # Ignore non-stylesheet rel tags
25
+ rel = node.attributes["rel"]
26
+ return if rel && rel.value != "stylesheet"
56
27
 
57
- def variable_hosted_by_shopify?(variable_node)
58
- variable_node.value.filters
59
- .any? { |(filter_name, *_filter_args)| ASSET_URL_FILTERS.include?(filter_name) }
28
+ add_offense(
29
+ "Asset should be served by the Shopify CDN for better performance.",
30
+ node: node,
31
+ )
60
32
  end
61
33
 
62
- # This part is slightly more complicated because we don't have an
63
- # HTML AST. We have to resort to looking at the HTML with regexes
64
- # to figure out if we have a resource (stylesheet, script, or media)
65
- # that points to a remote domain.
66
- def record_html_offenses(node, source)
67
- matches(source, RESOURCE_TAG).each do |match|
68
- tag = match[0]
69
-
70
- # We don't flag stuff without URLs
71
- next unless tag =~ RESOURCE_URL
72
- resource_match = Regexp.last_match
73
- resource_url = resource_match[:resource_url].gsub(START_OR_END_QUOTE, '')
74
-
75
- next if non_stylesheet_link?(tag)
76
- next if url_hosted_by_shopify?(resource_url)
77
- next if resource_url =~ ABSOLUTE_PATH
78
- next if resource_url =~ RELATIVE_PATH
79
- next if resource_url.empty?
80
-
81
- start = match.begin(0) + resource_match.begin(:resource_url)
82
- add_offense(
83
- OFFENSE_MESSAGE,
84
- node: node,
85
- markup: resource_url,
86
- line_number: source[0...start].count("\n") + 1,
87
- )
88
- end
89
- end
90
-
91
- def non_stylesheet_link?(tag)
92
- tag =~ REL && !(Regexp.last_match[:rel] =~ /\A['"]stylesheet['"]\Z/)
93
- end
34
+ private
94
35
 
95
36
  def url_hosted_by_shopify?(url)
96
- url =~ /\A#{VARIABLE}\Z/oim && url =~ ASSET_URL_FILTER
37
+ url.start_with?(Liquid::VariableStart) &&
38
+ AssetUrlFilters::ASSET_URL_FILTERS.any? { |filter| url.include?(filter) }
97
39
  end
98
40
  end
99
41
  end
@@ -15,17 +15,17 @@ module ThemeCheck
15
15
  return if :assign == node.type_name
16
16
 
17
17
  outside_of_strings(node.markup) do |chunk|
18
- chunk.scan(/([,:|]) +/) do |_match|
18
+ chunk.scan(/([,:|]|==|<>|<=|>=|<|>|!=) +/) do |_match|
19
19
  add_offense("Too many spaces after '#{Regexp.last_match(1)}'", node: node, markup: Regexp.last_match(0))
20
20
  end
21
- chunk.scan(/([,:|])\S/) do |_match|
21
+ chunk.scan(/([,:|]|==|<>|<=|>=|<\b|>\b|!=)(\S|\z)/) do |_match|
22
22
  add_offense("Space missing after '#{Regexp.last_match(1)}'", node: node, markup: Regexp.last_match(0))
23
23
  end
24
- chunk.scan(/ ([|])+/) do |_match|
24
+ chunk.scan(/ (\||==|<>|<=|>=|<|>|!=)+/) do |_match|
25
25
  add_offense("Too many spaces before '#{Regexp.last_match(1)}'", node: node, markup: Regexp.last_match(0))
26
26
  end
27
- chunk.scan(/\A(?<pipe>\|)|\S(?<pipe>\|)/) do |_match|
28
- add_offense("Space missing before '#{Regexp.last_match(:pipe)}'", node: node, markup: Regexp.last_match(:pipe))
27
+ 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))
29
29
  end
30
30
  end
31
31
  end
@@ -8,6 +8,9 @@ module ThemeCheck
8
8
  def initialize(max_length: 200, exclude_schema: true)
9
9
  @max_length = max_length
10
10
  @exclude_schema = exclude_schema
11
+ end
12
+
13
+ def on_document(_node)
11
14
  @excluded_lines = 0
12
15
  end
13
16
 
@@ -5,6 +5,7 @@ require 'nokogumbo'
5
5
  module ThemeCheck
6
6
  class ValidHTMLTranslation < JsonCheck
7
7
  severity :suggestion
8
+ category :translation
8
9
  doc docs_url(__FILE__)
9
10
 
10
11
  def on_file(file)
@@ -91,11 +91,13 @@ module ThemeCheck
91
91
 
92
92
  options_for_check = options.transform_keys(&:to_sym)
93
93
  options_for_check.delete(:enabled)
94
+ ignored_patterns = options_for_check.delete(:ignore) || []
94
95
  check = if options_for_check.empty?
95
96
  check_class.new
96
97
  else
97
98
  check_class.new(**options_for_check)
98
99
  end
100
+ check.ignored_patterns = ignored_patterns
99
101
  check.options = options_for_check
100
102
  check
101
103
  end.compact
@@ -4,10 +4,11 @@
4
4
  # We'll use the node position to figure out if the test is disabled or not.
5
5
  module ThemeCheck
6
6
  class DisabledCheck
7
- attr_reader :name, :ranges
7
+ attr_reader :name, :template, :ranges
8
8
  attr_accessor :first_line
9
9
 
10
- def initialize(name)
10
+ def initialize(template, name)
11
+ @template = template
11
12
  @name = name
12
13
  @ranges = []
13
14
  @first_line = false
@@ -24,7 +25,8 @@ module ThemeCheck
24
25
  end
25
26
 
26
27
  def disabled?(index)
27
- ranges.any? { |range| range.cover?(index) }
28
+ index == 0 && first_line ||
29
+ ranges.any? { |range| range.cover?(index) }
28
30
  end
29
31
 
30
32
  def last
@@ -33,7 +35,7 @@ module ThemeCheck
33
35
 
34
36
  def missing_end_index?
35
37
  return false if first_line && ranges.size == 1
36
- last.end.nil?
38
+ last&.end.nil?
37
39
  end
38
40
  end
39
41
  end
@@ -10,28 +10,36 @@ module ThemeCheck
10
10
  ACTION_ENABLE_CHECKS = :enable
11
11
 
12
12
  def initialize
13
- @disabled_checks = {}
13
+ @disabled_checks = Hash.new do |hash, key|
14
+ template, check_name = key
15
+ hash[key] = DisabledCheck.new(template, check_name)
16
+ end
14
17
  end
15
18
 
16
19
  def update(node)
17
20
  text = comment_text(node)
18
21
  if start_disabling?(text)
19
22
  checks_from_text(text).each do |check_name|
20
- @disabled_checks[check_name] ||= DisabledCheck.new(check_name)
21
- @disabled_checks[check_name].start_index = node.start_index
22
- @disabled_checks[check_name].first_line = true if node.line_number == 1
23
+ disabled = @disabled_checks[[node.template, check_name]]
24
+ disabled.start_index = node.start_index
25
+ disabled.first_line = true if node.line_number == 1
23
26
  end
24
27
  elsif stop_disabling?(text)
25
28
  checks_from_text(text).each do |check_name|
26
- next unless @disabled_checks.key?(check_name)
27
- @disabled_checks[check_name].end_index = node.end_index
29
+ disabled = @disabled_checks[[node.template, check_name]]
30
+ next unless disabled
31
+ disabled.end_index = node.end_index
28
32
  end
29
33
  end
30
34
  end
31
35
 
32
- def disabled?(key, index)
33
- @disabled_checks[:all]&.disabled?(index) ||
34
- @disabled_checks[key]&.disabled?(index)
36
+ def disabled?(check, template, check_name, index)
37
+ return true if check.ignored_patterns&.any? do |pattern|
38
+ template.relative_path.fnmatch?(pattern)
39
+ end
40
+
41
+ @disabled_checks[[template, :all]]&.disabled?(index) ||
42
+ @disabled_checks[[template, check_name]]&.disabled?(index)
35
43
  end
36
44
 
37
45
  def checks_missing_end_index
@@ -40,6 +48,14 @@ module ThemeCheck
40
48
  .map(&:name)
41
49
  end
42
50
 
51
+ def remove_disabled_offenses(checks)
52
+ checks.disableable.each do |check|
53
+ check.offenses.reject! do |offense|
54
+ disabled?(check, offense.template, offense.code_name, offense.start_index)
55
+ end
56
+ end
57
+ end
58
+
43
59
  private
44
60
 
45
61
  def comment_text(node)