theme-check 0.8.2 → 0.10.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +3 -0
  3. data/CHANGELOG.md +45 -0
  4. data/CONTRIBUTING.md +20 -90
  5. data/README.md +4 -1
  6. data/RELEASING.md +5 -3
  7. data/Rakefile +31 -0
  8. data/config/default.yml +45 -0
  9. data/docs/api/check.md +15 -0
  10. data/docs/api/html_check.md +46 -0
  11. data/docs/api/json_check.md +19 -0
  12. data/docs/api/liquid_check.md +99 -0
  13. data/docs/checks/{CHECK_DOCS_TEMPLATE.md → TEMPLATE.md.erb} +5 -5
  14. data/docs/checks/asset_url_filters.md +56 -0
  15. data/docs/checks/content_for_header_modification.md +42 -0
  16. data/docs/checks/img_lazy_loading.md +61 -0
  17. data/docs/checks/parser_blocking_script_tag.md +53 -0
  18. data/exe/theme-check-language-server +1 -2
  19. data/lib/theme_check.rb +8 -1
  20. data/lib/theme_check/analyzer.rb +72 -16
  21. data/lib/theme_check/bug.rb +1 -0
  22. data/lib/theme_check/check.rb +32 -7
  23. data/lib/theme_check/checks.rb +9 -1
  24. data/lib/theme_check/checks/TEMPLATE.rb.erb +11 -0
  25. data/lib/theme_check/checks/asset_url_filters.rb +46 -0
  26. data/lib/theme_check/checks/content_for_header_modification.rb +41 -0
  27. data/lib/theme_check/checks/img_lazy_loading.rb +25 -0
  28. data/lib/theme_check/checks/img_width_and_height.rb +18 -49
  29. data/lib/theme_check/checks/missing_template.rb +1 -0
  30. data/lib/theme_check/checks/parser_blocking_javascript.rb +6 -38
  31. data/lib/theme_check/checks/parser_blocking_script_tag.rb +20 -0
  32. data/lib/theme_check/checks/remote_asset.rb +21 -79
  33. data/lib/theme_check/checks/template_length.rb +3 -0
  34. data/lib/theme_check/checks/valid_html_translation.rb +1 -0
  35. data/lib/theme_check/config.rb +2 -0
  36. data/lib/theme_check/disabled_check.rb +6 -4
  37. data/lib/theme_check/disabled_checks.rb +25 -9
  38. data/lib/theme_check/html_check.rb +7 -0
  39. data/lib/theme_check/html_node.rb +56 -0
  40. data/lib/theme_check/html_visitor.rb +38 -0
  41. data/lib/theme_check/json_file.rb +13 -0
  42. data/lib/theme_check/language_server.rb +1 -0
  43. data/lib/theme_check/language_server/completion_providers/filter_completion_provider.rb +1 -0
  44. data/lib/theme_check/language_server/diagnostics_tracker.rb +66 -0
  45. data/lib/theme_check/language_server/handler.rb +31 -26
  46. data/lib/theme_check/language_server/server.rb +1 -1
  47. data/lib/theme_check/liquid_check.rb +1 -4
  48. data/lib/theme_check/offense.rb +18 -0
  49. data/lib/theme_check/template.rb +9 -0
  50. data/lib/theme_check/theme.rb +7 -2
  51. data/lib/theme_check/version.rb +1 -1
  52. data/lib/theme_check/visitor.rb +2 -11
  53. metadata +20 -3
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ require 'theme_check/version'
2
3
 
3
4
  module ThemeCheck
4
5
  BUG_POSTAMBLE = <<~EOS
@@ -6,7 +6,7 @@ module ThemeCheck
6
6
  include JsonHelpers
7
7
 
8
8
  attr_accessor :theme
9
- attr_accessor :options
9
+ attr_accessor :options, :ignored_patterns
10
10
  attr_writer :offenses
11
11
 
12
12
  SEVERITIES = [
@@ -18,7 +18,7 @@ module ThemeCheck
18
18
  CATEGORIES = [
19
19
  :liquid,
20
20
  :translation,
21
- :performance,
21
+ :html,
22
22
  :json,
23
23
  :performance,
24
24
  ]
@@ -67,12 +67,23 @@ module ThemeCheck
67
67
  end
68
68
  defined?(@can_disable) ? @can_disable : true
69
69
  end
70
+
71
+ def single_file(single_file = nil)
72
+ unless single_file.nil?
73
+ @single_file = single_file
74
+ end
75
+ defined?(@single_file) ? @single_file : !method_defined?(:on_end)
76
+ end
70
77
  end
71
78
 
72
79
  def offenses
73
80
  @offenses ||= []
74
81
  end
75
82
 
83
+ def add_offense(message, node: nil, template: node&.template, markup: nil, line_number: nil, &block)
84
+ offenses << Offense.new(check: self, message: message, template: template, node: node, markup: markup, line_number: line_number, correction: block)
85
+ end
86
+
76
87
  def severity
77
88
  self.class.severity
78
89
  end
@@ -93,10 +104,6 @@ module ThemeCheck
93
104
  @ignored = true
94
105
  end
95
106
 
96
- def unignore!
97
- @ignored = false
98
- end
99
-
100
107
  def ignored?
101
108
  defined?(@ignored) && @ignored
102
109
  end
@@ -105,9 +112,27 @@ module ThemeCheck
105
112
  self.class.can_disable
106
113
  end
107
114
 
115
+ def single_file?
116
+ self.class.single_file
117
+ end
118
+
119
+ def whole_theme?
120
+ !single_file?
121
+ end
122
+
123
+ def ==(other)
124
+ other.is_a?(Check) && code_name == other.code_name
125
+ end
126
+ alias_method :eql?, :==
127
+
108
128
  def to_s
109
129
  s = +"#{code_name}:\n"
110
- properties = { severity: severity, categories: categories, doc: doc }.merge(options)
130
+ properties = {
131
+ severity: severity,
132
+ categories: categories,
133
+ doc: doc,
134
+ ignored_patterns: ignored_patterns,
135
+ }.merge(options)
111
136
  properties.each_pair do |name, value|
112
137
  s << " #{name}: #{value}\n" if value
113
138
  end
@@ -13,7 +13,15 @@ module ThemeCheck
13
13
  end
14
14
 
15
15
  def disableable
16
- self.class.new(select(&:can_disable?))
16
+ @disableable ||= self.class.new(select(&:can_disable?))
17
+ end
18
+
19
+ def whole_theme
20
+ @whole_theme ||= self.class.new(select(&:whole_theme?))
21
+ end
22
+
23
+ def single_file
24
+ @single_file ||= self.class.new(select(&:single_file?))
17
25
  end
18
26
 
19
27
  private
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ # TODO: inherit from HtmlCheck or JsonCheck if working on a non-Liquid check
4
+ class <%= class_name %> < LiquidCheck
5
+ severity :suggestion
6
+ category :liquid
7
+ doc docs_url(__FILE__)
8
+
9
+ # TODO: def on_<NODE_TYPE>
10
+ end
11
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class AssetUrlFilters < LiquidCheck
4
+ severity :suggestion
5
+ categories :liquid, :performance
6
+ doc docs_url(__FILE__)
7
+
8
+ HTML_FILTERS = [
9
+ 'stylesheet_tag',
10
+ 'script_tag',
11
+ 'img_tag',
12
+ ]
13
+ ASSET_URL_FILTERS = [
14
+ 'asset_url',
15
+ 'asset_img_url',
16
+ 'file_img_url',
17
+ 'file_url',
18
+ 'global_asset_url',
19
+ 'img_url',
20
+ 'payment_type_img_url',
21
+ 'shopify_asset_url',
22
+ ]
23
+
24
+ def on_variable(node)
25
+ record_variable_offense(node)
26
+ end
27
+
28
+ private
29
+
30
+ def record_variable_offense(variable_node)
31
+ # We flag HTML tags with URLs not hosted by Shopify
32
+ return if !html_resource_drop?(variable_node) || variable_hosted_by_shopify?(variable_node)
33
+ add_offense("Use one of the asset_url filters to serve assets", node: variable_node)
34
+ end
35
+
36
+ def html_resource_drop?(variable_node)
37
+ variable_node.value.filters
38
+ .any? { |(filter_name, *_filter_args)| HTML_FILTERS.include?(filter_name) }
39
+ end
40
+
41
+ def variable_hosted_by_shopify?(variable_node)
42
+ variable_node.value.filters
43
+ .any? { |(filter_name, *_filter_args)| ASSET_URL_FILTERS.include?(filter_name) }
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class ContentForHeaderModification < LiquidCheck
4
+ severity :error
5
+ category :liquid
6
+ doc docs_url(__FILE__)
7
+
8
+ def initialize
9
+ @in_assign = false
10
+ @in_capture = false
11
+ end
12
+
13
+ def on_variable(node)
14
+ return unless node.value.name.is_a?(Liquid::VariableLookup)
15
+ return unless node.value.name.name == "content_for_header"
16
+
17
+ if @in_assign || @in_capture || node.value.filters.any?
18
+ add_offense(
19
+ "Do not rely on the content of `content_for_header`",
20
+ node: node,
21
+ )
22
+ end
23
+ end
24
+
25
+ def on_assign(_node)
26
+ @in_assign = true
27
+ end
28
+
29
+ def after_assign(_node)
30
+ @in_assign = false
31
+ end
32
+
33
+ def on_capture(_node)
34
+ @in_capture = true
35
+ end
36
+
37
+ def after_capture(_node)
38
+ @in_capture = false
39
+ end
40
+ end
41
+ end
@@ -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
@@ -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