theme-check 0.8.0 → 0.9.1

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +3 -0
  3. data/CHANGELOG.md +44 -0
  4. data/CONTRIBUTING.md +2 -1
  5. data/README.md +4 -1
  6. data/RELEASING.md +5 -3
  7. data/config/default.yml +42 -1
  8. data/data/shopify_liquid/tags.yml +3 -0
  9. data/data/shopify_translation_keys.yml +1 -0
  10. data/docs/checks/asset_url_filters.md +56 -0
  11. data/docs/checks/content_for_header_modification.md +42 -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 +28 -0
  15. data/exe/theme-check-language-server +1 -2
  16. data/lib/theme_check.rb +13 -1
  17. data/lib/theme_check/analyzer.rb +79 -13
  18. data/lib/theme_check/bug.rb +20 -0
  19. data/lib/theme_check/check.rb +36 -7
  20. data/lib/theme_check/checks.rb +47 -8
  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_width_and_height.rb +18 -49
  24. data/lib/theme_check/checks/missing_enable_comment.rb +4 -4
  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 +8 -2
  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 +41 -0
  35. data/lib/theme_check/disabled_checks.rb +33 -29
  36. data/lib/theme_check/exceptions.rb +32 -0
  37. data/lib/theme_check/html_check.rb +7 -0
  38. data/lib/theme_check/html_node.rb +56 -0
  39. data/lib/theme_check/html_visitor.rb +38 -0
  40. data/lib/theme_check/json_file.rb +13 -1
  41. data/lib/theme_check/language_server.rb +2 -1
  42. data/lib/theme_check/language_server/completion_engine.rb +1 -1
  43. data/lib/theme_check/language_server/completion_providers/filter_completion_provider.rb +1 -0
  44. data/lib/theme_check/language_server/completion_providers/object_completion_provider.rb +10 -8
  45. data/lib/theme_check/language_server/constants.rb +5 -1
  46. data/lib/theme_check/language_server/diagnostics_tracker.rb +64 -0
  47. data/lib/theme_check/language_server/document_link_engine.rb +2 -2
  48. data/lib/theme_check/language_server/handler.rb +63 -50
  49. data/lib/theme_check/language_server/server.rb +1 -1
  50. data/lib/theme_check/language_server/variable_lookup_finder.rb +295 -0
  51. data/lib/theme_check/liquid_check.rb +1 -4
  52. data/lib/theme_check/node.rb +12 -0
  53. data/lib/theme_check/offense.rb +30 -46
  54. data/lib/theme_check/position.rb +77 -0
  55. data/lib/theme_check/position_helper.rb +37 -0
  56. data/lib/theme_check/remote_asset_file.rb +3 -0
  57. data/lib/theme_check/shopify_liquid/tag.rb +13 -0
  58. data/lib/theme_check/template.rb +8 -0
  59. data/lib/theme_check/theme.rb +7 -2
  60. data/lib/theme_check/version.rb +1 -1
  61. data/lib/theme_check/visitor.rb +4 -14
  62. metadata +19 -4
  63. data/lib/theme_check/language_server/position_helper.rb +0 -27
@@ -17,13 +17,13 @@ module ThemeCheck
17
17
  end
18
18
 
19
19
  def after_document(node)
20
- return if @disabled_checks.full_document_disabled?
21
- return unless @disabled_checks.any?
20
+ checks_missing_end_index = @disabled_checks.checks_missing_end_index
21
+ return if checks_missing_end_index.empty?
22
22
 
23
- message = if @disabled_checks.all_disabled?
23
+ message = if checks_missing_end_index.any? { |name| name == :all }
24
24
  "All checks were"
25
25
  else
26
- @disabled_checks.all.join(', ') + " " + (@disabled_checks.all.size == 1 ? "was" : "were")
26
+ checks_missing_end_index.join(', ') + " " + (checks_missing_end_index.size == 1 ? "was" : "were")
27
27
  end
28
28
 
29
29
  add_offense("#{message} disabled but not re-enabled with theme-check-enable", node: node)
@@ -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,12 +15,18 @@ 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|
25
+ add_offense("Too many spaces before '#{Regexp.last_match(1)}'", node: node, markup: Regexp.last_match(0))
26
+ end
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
+ end
24
30
  end
25
31
  end
26
32
 
@@ -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
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This class keeps track of checks being turned on and off in ranges.
4
+ # We'll use the node position to figure out if the test is disabled or not.
5
+ module ThemeCheck
6
+ class DisabledCheck
7
+ attr_reader :name, :template, :ranges
8
+ attr_accessor :first_line
9
+
10
+ def initialize(template, name)
11
+ @template = template
12
+ @name = name
13
+ @ranges = []
14
+ @first_line = false
15
+ end
16
+
17
+ def start_index=(index)
18
+ return unless ranges.empty? || !last.end.nil?
19
+ @ranges << (index..)
20
+ end
21
+
22
+ def end_index=(index)
23
+ return if ranges.empty? || !last.end.nil?
24
+ @ranges << (@ranges.pop.begin..index)
25
+ end
26
+
27
+ def disabled?(index)
28
+ index == 0 && first_line ||
29
+ ranges.any? { |range| range.cover?(index) }
30
+ end
31
+
32
+ def last
33
+ ranges.last
34
+ end
35
+
36
+ def missing_end_index?
37
+ return false if first_line && ranges.size == 1
38
+ last&.end.nil?
39
+ end
40
+ end
41
+ end
@@ -8,50 +8,52 @@ module ThemeCheck
8
8
 
9
9
  ACTION_DISABLE_CHECKS = :disable
10
10
  ACTION_ENABLE_CHECKS = :enable
11
- ACTION_UNRELATED_COMMENT = :unrelated
12
11
 
13
12
  def initialize
14
- @disabled = []
15
- @all_disabled = false
16
- @full_document_disabled = false
13
+ @disabled_checks = Hash.new do |hash, key|
14
+ template, check_name = key
15
+ hash[key] = DisabledCheck.new(template, check_name)
16
+ end
17
17
  end
18
18
 
19
19
  def update(node)
20
20
  text = comment_text(node)
21
-
22
21
  if start_disabling?(text)
23
- @disabled = checks_from_text(text)
24
- @all_disabled = @disabled.empty?
25
-
26
- if node&.line_number == 1
27
- @full_document_disabled = true
22
+ checks_from_text(text).each do |check_name|
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
28
26
  end
29
27
  elsif stop_disabling?(text)
30
- checks = checks_from_text(text)
31
- @disabled = checks.empty? ? [] : @disabled - checks
32
-
33
- @all_disabled = false
28
+ checks_from_text(text).each do |check_name|
29
+ disabled = @disabled_checks[[node.template, check_name]]
30
+ next unless disabled
31
+ disabled.end_index = node.end_index
32
+ end
34
33
  end
35
34
  end
36
35
 
37
- # Whether any checks are currently disabled
38
- def any?
39
- !@disabled.empty? || @all_disabled
40
- end
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
41
40
 
42
- # Whether all checks should be disabled
43
- def all_disabled?
44
- @all_disabled
41
+ @disabled_checks[[template, :all]]&.disabled?(index) ||
42
+ @disabled_checks[[template, check_name]]&.disabled?(index)
45
43
  end
46
44
 
47
- # Get a list of all the individual disabled checks
48
- def all
49
- @disabled
45
+ def checks_missing_end_index
46
+ @disabled_checks.values
47
+ .select(&:missing_end_index?)
48
+ .map(&:name)
50
49
  end
51
50
 
52
- # If the first line of the document is a theme-check-disable comment
53
- def full_document_disabled?
54
- @full_document_disabled
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
55
57
  end
56
58
 
57
59
  private
@@ -69,9 +71,11 @@ module ThemeCheck
69
71
  end
70
72
 
71
73
  # Return a list of checks from a theme-check-disable comment
72
- # Returns [] if all checks are meant to be disabled
74
+ # Returns [:all] if all checks are meant to be disabled
73
75
  def checks_from_text(text)
74
- text.gsub(DISABLE_PREFIX_PATTERN, '').strip.split(',').map(&:strip)
76
+ checks = text.gsub(DISABLE_PREFIX_PATTERN, '').strip.split(',').map(&:strip)
77
+ return [:all] if checks.empty?
78
+ checks
75
79
  end
76
80
  end
77
81
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+ require "net/http"
3
+
4
+ TIMEOUT_EXCEPTIONS = [
5
+ Net::ReadTimeout,
6
+ Net::OpenTimeout,
7
+ Net::WriteTimeout,
8
+ Errno::ETIMEDOUT,
9
+ Timeout::Error,
10
+ ]
11
+
12
+ CONNECTION_EXCEPTIONS = [
13
+ IOError,
14
+ EOFError,
15
+ SocketError,
16
+ Errno::EINVAL,
17
+ Errno::ECONNRESET,
18
+ Errno::ECONNABORTED,
19
+ Errno::EPIPE,
20
+ Errno::ECONNREFUSED,
21
+ Errno::EAGAIN,
22
+ Errno::EHOSTUNREACH,
23
+ Errno::ENETUNREACH,
24
+ ]
25
+
26
+ NET_HTTP_EXCEPTIONS = [
27
+ Net::HTTPBadResponse,
28
+ Net::HTTPHeaderSyntaxError,
29
+ Net::ProtocolError,
30
+ *TIMEOUT_EXCEPTIONS,
31
+ *CONNECTION_EXCEPTIONS,
32
+ ]