theme-check 0.9.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/CONTRIBUTING.md +20 -91
  4. data/Rakefile +31 -0
  5. data/config/default.yml +31 -3
  6. data/data/shopify_liquid/objects.yml +2 -0
  7. data/docs/api/check.md +15 -0
  8. data/docs/api/html_check.md +46 -0
  9. data/docs/api/json_check.md +19 -0
  10. data/docs/api/liquid_check.md +99 -0
  11. data/docs/checks/{CHECK_DOCS_TEMPLATE.md → TEMPLATE.md.erb} +5 -5
  12. data/docs/checks/asset_size_css_stylesheet_tag.md +50 -0
  13. data/docs/checks/asset_url_filters.md +56 -0
  14. data/docs/checks/deprecate_bgsizes.md +66 -0
  15. data/docs/checks/deprecate_lazysizes.md +61 -0
  16. data/docs/checks/html_parsing_error.md +50 -0
  17. data/docs/checks/img_lazy_loading.md +61 -0
  18. data/docs/checks/liquid_tag.md +2 -2
  19. data/docs/checks/template_length.md +12 -2
  20. data/lib/theme_check/check.rb +1 -1
  21. data/lib/theme_check/checks/TEMPLATE.rb.erb +11 -0
  22. data/lib/theme_check/checks/asset_size_css.rb +11 -74
  23. data/lib/theme_check/checks/asset_size_css_stylesheet_tag.rb +24 -0
  24. data/lib/theme_check/checks/asset_size_javascript.rb +10 -36
  25. data/lib/theme_check/checks/asset_url_filters.rb +46 -0
  26. data/lib/theme_check/checks/deprecate_bgsizes.rb +14 -0
  27. data/lib/theme_check/checks/deprecate_lazysizes.rb +16 -0
  28. data/lib/theme_check/checks/html_parsing_error.rb +12 -0
  29. data/lib/theme_check/checks/img_lazy_loading.rb +20 -0
  30. data/lib/theme_check/checks/liquid_tag.rb +2 -2
  31. data/lib/theme_check/checks/remote_asset.rb +23 -79
  32. data/lib/theme_check/checks/template_length.rb +18 -4
  33. data/lib/theme_check/html_check.rb +2 -0
  34. data/lib/theme_check/html_node.rb +4 -0
  35. data/lib/theme_check/html_visitor.rb +5 -1
  36. data/lib/theme_check/json_file.rb +5 -0
  37. data/lib/theme_check/language_server/diagnostics_tracker.rb +2 -0
  38. data/lib/theme_check/liquid_check.rb +0 -11
  39. data/lib/theme_check/offense.rb +5 -5
  40. data/lib/theme_check/parsing_helpers.rb +3 -1
  41. data/lib/theme_check/regex_helpers.rb +17 -0
  42. data/lib/theme_check/tags.rb +37 -0
  43. data/lib/theme_check/template.rb +1 -0
  44. data/lib/theme_check/version.rb +1 -1
  45. metadata +21 -4
@@ -1,89 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
  module ThemeCheck
3
- class AssetSizeCSS < LiquidCheck
3
+ class AssetSizeCSS < HtmlCheck
4
4
  include RegexHelpers
5
5
  severity :error
6
- category :performance
6
+ category :html, :performance
7
7
  doc docs_url(__FILE__)
8
8
 
9
- Link = Struct.new(:href, :index)
10
-
11
- LINK_TAG_HREF = %r{
12
- <link
13
- (?=[^>]+?rel=['"]?stylesheet['"]?) # Make sure rel=stylesheet is in the link with lookahead
14
- [^>]+ # any non closing tag character
15
- href= # href attribute start
16
- (?<href>#{QUOTED_LIQUID_ATTRIBUTE}) # href attribute value (may contain liquid)
17
- [^>]* # any non closing character till the end
18
- >
19
- }omix
20
- STYLESHEET_TAG = %r{
21
- #{Liquid::VariableStart} # VariableStart
22
- (?:(?!#{Liquid::VariableEnd}).)*? # anything that isn't followed by a VariableEnd
23
- \|\s*asset_url\s* # | asset_url
24
- \|\s*stylesheet_tag\s* # | stylesheet_tag
25
- #{Liquid::VariableEnd} # VariableEnd
26
- }omix
27
-
28
9
  attr_reader :threshold_in_bytes
29
10
 
30
11
  def initialize(threshold_in_bytes: 100_000)
31
12
  @threshold_in_bytes = threshold_in_bytes
32
13
  end
33
14
 
34
- def on_document(node)
35
- @node = node
36
- @source = node.template.source
37
- record_offenses
38
- end
39
-
40
- def record_offenses
41
- stylesheets(@source).each do |stylesheet|
42
- file_size = href_to_file_size(stylesheet.href)
43
- next if file_size.nil?
44
- next if file_size <= threshold_in_bytes
45
- add_offense(
46
- "CSS on every page load exceding compressed size threshold (#{threshold_in_bytes} Bytes).",
47
- node: @node,
48
- markup: stylesheet.href,
49
- line_number: @source[0...stylesheet.index].count("\n") + 1
50
- )
51
- end
52
- end
53
-
54
- def stylesheets(source)
55
- stylesheet_links = matches(source, LINK_TAG_HREF)
56
- .map do |m|
57
- Link.new(
58
- m[:href].gsub(START_OR_END_QUOTE, ""),
59
- m.begin(:href),
60
- )
61
- end
62
-
63
- stylesheet_tags = matches(source, STYLESHEET_TAG)
64
- .map do |m|
65
- Link.new(
66
- m[0],
67
- m.begin(0),
68
- )
69
- end
70
-
71
- stylesheet_links + stylesheet_tags
72
- end
73
-
74
- def href_to_file_size(href)
75
- # asset_url (+ optional stylesheet_tag) variables
76
- if href =~ /^#{VARIABLE}$/o && href =~ /asset_url/ && href =~ Liquid::QuotedString
77
- asset_id = Regexp.last_match(0).gsub(START_OR_END_QUOTE, "")
78
- asset = @theme.assets.find { |a| a.name.end_with?("/" + asset_id) }
79
- return if asset.nil?
80
- asset.gzipped_size
81
-
82
- # remote URLs
83
- elsif href =~ %r{^(https?:)?//}
84
- asset = RemoteAssetFile.from_src(href)
85
- asset.gzipped_size
86
- end
15
+ def on_link(node)
16
+ return if node.attributes['rel']&.value != "stylesheet"
17
+ file_size = href_to_file_size(node.attributes['href']&.value)
18
+ return if file_size.nil?
19
+ return if file_size <= threshold_in_bytes
20
+ add_offense(
21
+ "CSS on every page load exceeding compressed size threshold (#{threshold_in_bytes} Bytes).",
22
+ node: node
23
+ )
87
24
  end
88
25
  end
89
26
  end
@@ -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,52 +3,26 @@ 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']&.value)
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)
@@ -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,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"]&.value&.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"]&.value&.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"]&.value == "auto"
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class HtmlParsingError < HtmlCheck
4
+ severity :error
5
+ category :html
6
+ doc docs_url(__FILE__)
7
+
8
+ def on_parse_error(exception, template)
9
+ add_offense("HTML in this template can not be parsed: #{exception.message}", template: template)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,20 @@
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
+ if loading == "auto"
14
+ add_offense("Prefer loading=\"lazy\" to defer loading of images", node: node)
15
+ else
16
+ add_offense("Add a loading=\"lazy\" attribute to defer loading of images", node: node)
17
+ end
18
+ end
19
+ end
20
+ 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
@@ -1,99 +1,43 @@
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
12
+ CDN_ROOT = "https://cdn.shopify.com/"
38
13
 
39
- def on_document(node)
40
- source = node.template.source
41
- record_html_offenses(node, source)
42
- end
14
+ def on_element(node)
15
+ return unless TAGS.include?(node.name)
43
16
 
44
- private
17
+ resource_url = node.attributes["src"]&.value || node.attributes["href"]&.value
18
+ return if resource_url.nil? || resource_url.empty?
45
19
 
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
20
+ # Ignore if URL is Liquid, taken care of by AssetUrlFilters check
21
+ return if resource_url.start_with?(CDN_ROOT)
22
+ return if resource_url =~ ABSOLUTE_PATH
23
+ return if resource_url =~ RELATIVE_PATH
24
+ return if url_hosted_by_shopify?(resource_url)
51
25
 
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
26
+ # Ignore non-stylesheet rel tags
27
+ rel = node.attributes["rel"]
28
+ return if rel && rel.value != "stylesheet"
56
29
 
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) }
30
+ add_offense(
31
+ "Asset should be served by the Shopify CDN for better performance.",
32
+ node: node,
33
+ )
60
34
  end
61
35
 
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
36
+ private
94
37
 
95
38
  def url_hosted_by_shopify?(url)
96
- url =~ /\A#{VARIABLE}\Z/oim && url =~ ASSET_URL_FILTER
39
+ url.start_with?(Liquid::VariableStart) &&
40
+ AssetUrlFilters::ASSET_URL_FILTERS.any? { |filter| url.include?(filter) }
97
41
  end
98
42
  end
99
43
  end