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.
- checksums.yaml +4 -4
- data/.github/workflows/theme-check.yml +3 -0
- data/CHANGELOG.md +44 -0
- data/CONTRIBUTING.md +2 -1
- data/README.md +4 -1
- data/RELEASING.md +5 -3
- data/config/default.yml +42 -1
- data/data/shopify_liquid/tags.yml +3 -0
- data/data/shopify_translation_keys.yml +1 -0
- data/docs/checks/asset_url_filters.md +56 -0
- data/docs/checks/content_for_header_modification.md +42 -0
- data/docs/checks/nested_snippet.md +1 -1
- data/docs/checks/parser_blocking_script_tag.md +53 -0
- data/docs/checks/space_inside_braces.md +28 -0
- data/exe/theme-check-language-server +1 -2
- data/lib/theme_check.rb +13 -1
- data/lib/theme_check/analyzer.rb +79 -13
- data/lib/theme_check/bug.rb +20 -0
- data/lib/theme_check/check.rb +36 -7
- data/lib/theme_check/checks.rb +47 -8
- data/lib/theme_check/checks/asset_url_filters.rb +46 -0
- data/lib/theme_check/checks/content_for_header_modification.rb +41 -0
- data/lib/theme_check/checks/img_width_and_height.rb +18 -49
- data/lib/theme_check/checks/missing_enable_comment.rb +4 -4
- data/lib/theme_check/checks/missing_template.rb +1 -0
- data/lib/theme_check/checks/nested_snippet.rb +1 -1
- data/lib/theme_check/checks/parser_blocking_javascript.rb +6 -38
- data/lib/theme_check/checks/parser_blocking_script_tag.rb +20 -0
- data/lib/theme_check/checks/remote_asset.rb +21 -79
- data/lib/theme_check/checks/space_inside_braces.rb +8 -2
- data/lib/theme_check/checks/template_length.rb +3 -0
- data/lib/theme_check/checks/valid_html_translation.rb +1 -0
- data/lib/theme_check/config.rb +2 -0
- data/lib/theme_check/disabled_check.rb +41 -0
- data/lib/theme_check/disabled_checks.rb +33 -29
- data/lib/theme_check/exceptions.rb +32 -0
- data/lib/theme_check/html_check.rb +7 -0
- data/lib/theme_check/html_node.rb +56 -0
- data/lib/theme_check/html_visitor.rb +38 -0
- data/lib/theme_check/json_file.rb +13 -1
- data/lib/theme_check/language_server.rb +2 -1
- data/lib/theme_check/language_server/completion_engine.rb +1 -1
- data/lib/theme_check/language_server/completion_providers/filter_completion_provider.rb +1 -0
- data/lib/theme_check/language_server/completion_providers/object_completion_provider.rb +10 -8
- data/lib/theme_check/language_server/constants.rb +5 -1
- data/lib/theme_check/language_server/diagnostics_tracker.rb +64 -0
- data/lib/theme_check/language_server/document_link_engine.rb +2 -2
- data/lib/theme_check/language_server/handler.rb +63 -50
- data/lib/theme_check/language_server/server.rb +1 -1
- data/lib/theme_check/language_server/variable_lookup_finder.rb +295 -0
- data/lib/theme_check/liquid_check.rb +1 -4
- data/lib/theme_check/node.rb +12 -0
- data/lib/theme_check/offense.rb +30 -46
- data/lib/theme_check/position.rb +77 -0
- data/lib/theme_check/position_helper.rb +37 -0
- data/lib/theme_check/remote_asset_file.rb +3 -0
- data/lib/theme_check/shopify_liquid/tag.rb +13 -0
- data/lib/theme_check/template.rb +8 -0
- data/lib/theme_check/theme.rb +7 -2
- data/lib/theme_check/version.rb +1 -1
- data/lib/theme_check/visitor.rb +4 -14
- metadata +19 -4
- 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
|
-
|
21
|
-
return
|
20
|
+
checks_missing_end_index = @disabled_checks.checks_missing_end_index
|
21
|
+
return if checks_missing_end_index.empty?
|
22
22
|
|
23
|
-
message = if
|
23
|
+
message = if checks_missing_end_index.any? { |name| name == :all }
|
24
24
|
"All checks were"
|
25
25
|
else
|
26
|
-
|
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)
|
@@ -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 <
|
5
|
-
include RegexHelpers
|
4
|
+
class ParserBlockingJavaScript < HtmlCheck
|
6
5
|
severity :error
|
7
|
-
categories :
|
6
|
+
categories :html, :performance
|
8
7
|
doc docs_url(__FILE__)
|
9
8
|
|
10
|
-
|
11
|
-
|
12
|
-
|
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
|
-
|
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 <
|
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
|
-
REL = /\srel=(?<rel>#{QUOTED_LIQUID_ATTRIBUTE})/oim
|
34
|
-
|
35
|
-
def on_variable(node)
|
36
|
-
record_variable_offense(node)
|
37
|
-
end
|
38
12
|
|
39
|
-
def
|
40
|
-
|
41
|
-
record_html_offenses(node, source)
|
42
|
-
end
|
13
|
+
def on_element(node)
|
14
|
+
return unless TAGS.include?(node.name)
|
43
15
|
|
44
|
-
|
16
|
+
resource_url = node.attributes["src"]&.value || node.attributes["href"]&.value
|
17
|
+
return if resource_url.nil? || resource_url.empty?
|
45
18
|
|
46
|
-
|
47
|
-
|
48
|
-
return if
|
49
|
-
|
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
|
-
|
53
|
-
|
54
|
-
|
55
|
-
end
|
24
|
+
# Ignore non-stylesheet rel tags
|
25
|
+
rel = node.attributes["rel"]
|
26
|
+
return if rel && rel.value != "stylesheet"
|
56
27
|
|
57
|
-
|
58
|
-
|
59
|
-
|
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
|
-
|
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
|
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(/([
|
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(/([
|
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
|
|
data/lib/theme_check/config.rb
CHANGED
@@ -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
|
-
@
|
15
|
-
|
16
|
-
|
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
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
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
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
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
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
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
|
-
|
43
|
-
|
44
|
-
@all_disabled
|
41
|
+
@disabled_checks[[template, :all]]&.disabled?(index) ||
|
42
|
+
@disabled_checks[[template, check_name]]&.disabled?(index)
|
45
43
|
end
|
46
44
|
|
47
|
-
|
48
|
-
|
49
|
-
|
45
|
+
def checks_missing_end_index
|
46
|
+
@disabled_checks.values
|
47
|
+
.select(&:missing_end_index?)
|
48
|
+
.map(&:name)
|
50
49
|
end
|
51
50
|
|
52
|
-
|
53
|
-
|
54
|
-
|
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
|
+
]
|