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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/CONTRIBUTING.md +20 -91
- data/Rakefile +31 -0
- data/config/default.yml +31 -3
- data/data/shopify_liquid/objects.yml +2 -0
- data/docs/api/check.md +15 -0
- data/docs/api/html_check.md +46 -0
- data/docs/api/json_check.md +19 -0
- data/docs/api/liquid_check.md +99 -0
- data/docs/checks/{CHECK_DOCS_TEMPLATE.md → TEMPLATE.md.erb} +5 -5
- data/docs/checks/asset_size_css_stylesheet_tag.md +50 -0
- data/docs/checks/asset_url_filters.md +56 -0
- data/docs/checks/deprecate_bgsizes.md +66 -0
- data/docs/checks/deprecate_lazysizes.md +61 -0
- data/docs/checks/html_parsing_error.md +50 -0
- data/docs/checks/img_lazy_loading.md +61 -0
- data/docs/checks/liquid_tag.md +2 -2
- data/docs/checks/template_length.md +12 -2
- data/lib/theme_check/check.rb +1 -1
- data/lib/theme_check/checks/TEMPLATE.rb.erb +11 -0
- data/lib/theme_check/checks/asset_size_css.rb +11 -74
- data/lib/theme_check/checks/asset_size_css_stylesheet_tag.rb +24 -0
- data/lib/theme_check/checks/asset_size_javascript.rb +10 -36
- data/lib/theme_check/checks/asset_url_filters.rb +46 -0
- data/lib/theme_check/checks/deprecate_bgsizes.rb +14 -0
- data/lib/theme_check/checks/deprecate_lazysizes.rb +16 -0
- data/lib/theme_check/checks/html_parsing_error.rb +12 -0
- data/lib/theme_check/checks/img_lazy_loading.rb +20 -0
- data/lib/theme_check/checks/liquid_tag.rb +2 -2
- data/lib/theme_check/checks/remote_asset.rb +23 -79
- data/lib/theme_check/checks/template_length.rb +18 -4
- data/lib/theme_check/html_check.rb +2 -0
- data/lib/theme_check/html_node.rb +4 -0
- data/lib/theme_check/html_visitor.rb +5 -1
- data/lib/theme_check/json_file.rb +5 -0
- data/lib/theme_check/language_server/diagnostics_tracker.rb +2 -0
- data/lib/theme_check/liquid_check.rb +0 -11
- data/lib/theme_check/offense.rb +5 -5
- data/lib/theme_check/parsing_helpers.rb +3 -1
- data/lib/theme_check/regex_helpers.rb +17 -0
- data/lib/theme_check/tags.rb +37 -0
- data/lib/theme_check/template.rb +1 -0
- data/lib/theme_check/version.rb +1 -1
- metadata +21 -4
@@ -1,89 +1,26 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
module ThemeCheck
|
3
|
-
class AssetSizeCSS <
|
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
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
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 <
|
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
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
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
|
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:
|
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 <
|
4
|
-
include RegexHelpers
|
3
|
+
class RemoteAsset < HtmlCheck
|
5
4
|
severity :suggestion
|
6
|
-
categories :
|
5
|
+
categories :html, :performance
|
7
6
|
doc docs_url(__FILE__)
|
8
7
|
|
9
|
-
|
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
|
-
|
34
|
-
|
35
|
-
def on_variable(node)
|
36
|
-
record_variable_offense(node)
|
37
|
-
end
|
12
|
+
CDN_ROOT = "https://cdn.shopify.com/"
|
38
13
|
|
39
|
-
def
|
40
|
-
|
41
|
-
record_html_offenses(node, source)
|
42
|
-
end
|
14
|
+
def on_element(node)
|
15
|
+
return unless TAGS.include?(node.name)
|
43
16
|
|
44
|
-
|
17
|
+
resource_url = node.attributes["src"]&.value || node.attributes["href"]&.value
|
18
|
+
return if resource_url.nil? || resource_url.empty?
|
45
19
|
|
46
|
-
|
47
|
-
|
48
|
-
return if
|
49
|
-
|
50
|
-
|
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
|
-
|
53
|
-
|
54
|
-
|
55
|
-
end
|
26
|
+
# Ignore non-stylesheet rel tags
|
27
|
+
rel = node.attributes["rel"]
|
28
|
+
return if rel && rel.value != "stylesheet"
|
56
29
|
|
57
|
-
|
58
|
-
|
59
|
-
|
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
|
-
|
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
|
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
|