theme-check 0.3.2 → 0.7.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/theme-check.yml +10 -3
- data/.rubocop.yml +12 -3
- data/CHANGELOG.md +42 -0
- data/CONTRIBUTING.md +5 -2
- data/Gemfile +5 -3
- data/LICENSE.md +2 -0
- data/README.md +12 -4
- data/RELEASING.md +10 -3
- data/Rakefile +6 -0
- data/config/default.yml +16 -0
- data/data/shopify_liquid/tags.yml +27 -0
- data/data/shopify_translation_keys.yml +850 -0
- data/docs/checks/CHECK_DOCS_TEMPLATE.md +47 -0
- data/docs/checks/asset_size_css.md +52 -0
- data/docs/checks/asset_size_javascript.md +79 -0
- data/docs/checks/convert_include_to_render.md +48 -0
- data/docs/checks/default_locale.md +46 -0
- data/docs/checks/deprecated_filter.md +46 -0
- data/docs/checks/img_width_and_height.md +79 -0
- data/docs/checks/liquid_tag.md +65 -0
- data/docs/checks/matching_schema_translations.md +93 -0
- data/docs/checks/matching_translations.md +72 -0
- data/docs/checks/missing_enable_comment.md +50 -0
- data/docs/checks/missing_required_template_files.md +26 -0
- data/docs/checks/missing_template.md +40 -0
- data/docs/checks/nested_snippet.md +69 -0
- data/docs/checks/parser_blocking_javascript.md +97 -0
- data/docs/checks/remote_asset.md +82 -0
- data/docs/checks/required_directories.md +25 -0
- data/docs/checks/required_layout_theme_object.md +28 -0
- data/docs/checks/space_inside_braces.md +63 -0
- data/docs/checks/syntax_error.md +49 -0
- data/docs/checks/template_length.md +50 -0
- data/docs/checks/translation_key_exists.md +63 -0
- data/docs/checks/undefined_object.md +53 -0
- data/docs/checks/unknown_filter.md +45 -0
- data/docs/checks/unused_assign.md +47 -0
- data/docs/checks/unused_snippet.md +32 -0
- data/docs/checks/valid_html_translation.md +53 -0
- data/docs/checks/valid_json.md +60 -0
- data/docs/checks/valid_schema.md +50 -0
- data/lib/theme_check.rb +4 -0
- data/lib/theme_check/asset_file.rb +34 -0
- data/lib/theme_check/check.rb +20 -10
- data/lib/theme_check/checks/asset_size_css.rb +89 -0
- data/lib/theme_check/checks/asset_size_javascript.rb +68 -0
- data/lib/theme_check/checks/convert_include_to_render.rb +1 -1
- data/lib/theme_check/checks/default_locale.rb +1 -0
- data/lib/theme_check/checks/deprecated_filter.rb +1 -1
- data/lib/theme_check/checks/img_width_and_height.rb +74 -0
- data/lib/theme_check/checks/liquid_tag.rb +3 -3
- data/lib/theme_check/checks/matching_schema_translations.rb +1 -0
- data/lib/theme_check/checks/matching_translations.rb +2 -1
- data/lib/theme_check/checks/missing_enable_comment.rb +1 -0
- data/lib/theme_check/checks/missing_required_template_files.rb +1 -2
- data/lib/theme_check/checks/missing_template.rb +1 -0
- data/lib/theme_check/checks/nested_snippet.rb +1 -0
- data/lib/theme_check/checks/parser_blocking_javascript.rb +8 -15
- data/lib/theme_check/checks/remote_asset.rb +98 -0
- data/lib/theme_check/checks/required_directories.rb +1 -1
- data/lib/theme_check/checks/required_layout_theme_object.rb +1 -1
- data/lib/theme_check/checks/space_inside_braces.rb +1 -0
- data/lib/theme_check/checks/syntax_error.rb +1 -0
- data/lib/theme_check/checks/template_length.rb +1 -0
- data/lib/theme_check/checks/translation_key_exists.rb +14 -1
- data/lib/theme_check/checks/undefined_object.rb +16 -7
- data/lib/theme_check/checks/unknown_filter.rb +1 -0
- data/lib/theme_check/checks/unused_assign.rb +5 -3
- data/lib/theme_check/checks/unused_snippet.rb +1 -0
- data/lib/theme_check/checks/valid_html_translation.rb +2 -1
- data/lib/theme_check/checks/valid_json.rb +1 -0
- data/lib/theme_check/checks/valid_schema.rb +1 -0
- data/lib/theme_check/cli.rb +49 -13
- data/lib/theme_check/config.rb +5 -2
- data/lib/theme_check/disabled_checks.rb +2 -2
- data/lib/theme_check/in_memory_storage.rb +13 -8
- data/lib/theme_check/language_server.rb +12 -0
- data/lib/theme_check/language_server/completion_engine.rb +38 -0
- data/lib/theme_check/language_server/completion_helper.rb +25 -0
- data/lib/theme_check/language_server/completion_provider.rb +28 -0
- data/lib/theme_check/language_server/completion_providers/filter_completion_provider.rb +51 -0
- data/lib/theme_check/language_server/completion_providers/object_completion_provider.rb +31 -0
- data/lib/theme_check/language_server/completion_providers/render_snippet_completion_provider.rb +43 -0
- data/lib/theme_check/language_server/completion_providers/tag_completion_provider.rb +31 -0
- data/lib/theme_check/language_server/constants.rb +10 -0
- data/lib/theme_check/language_server/document_link_engine.rb +48 -0
- data/lib/theme_check/language_server/handler.rb +105 -10
- data/lib/theme_check/language_server/position_helper.rb +27 -0
- data/lib/theme_check/language_server/protocol.rb +41 -0
- data/lib/theme_check/language_server/server.rb +9 -4
- data/lib/theme_check/language_server/tokens.rb +55 -0
- data/lib/theme_check/liquid_check.rb +11 -0
- data/lib/theme_check/node.rb +1 -2
- data/lib/theme_check/offense.rb +52 -15
- data/lib/theme_check/regex_helpers.rb +15 -0
- data/lib/theme_check/releaser.rb +39 -0
- data/lib/theme_check/remote_asset_file.rb +44 -0
- data/lib/theme_check/shopify_liquid.rb +1 -0
- data/lib/theme_check/shopify_liquid/deprecated_filter.rb +10 -8
- data/lib/theme_check/shopify_liquid/filter.rb +3 -5
- data/lib/theme_check/shopify_liquid/object.rb +2 -6
- data/lib/theme_check/shopify_liquid/tag.rb +14 -0
- data/lib/theme_check/storage.rb +3 -3
- data/lib/theme_check/string_helpers.rb +47 -0
- data/lib/theme_check/tags.rb +1 -2
- data/lib/theme_check/theme.rb +7 -1
- data/lib/theme_check/version.rb +1 -1
- data/theme-check.gemspec +1 -2
- metadata +57 -18
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module ThemeCheck
|
3
|
+
# Reports errors when trying to use too much JavaScript on page load
|
4
|
+
# Encourages the use of the Import on Interaction pattern [1].
|
5
|
+
# [1]: https://addyosmani.com/blog/import-on-interaction/
|
6
|
+
class AssetSizeJavaScript < LiquidCheck
|
7
|
+
include RegexHelpers
|
8
|
+
severity :error
|
9
|
+
category :performance
|
10
|
+
doc docs_url(__FILE__)
|
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
|
+
attr_reader :threshold_in_bytes
|
24
|
+
|
25
|
+
def initialize(threshold_in_bytes: 10000)
|
26
|
+
@threshold_in_bytes = threshold_in_bytes
|
27
|
+
end
|
28
|
+
|
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) }
|
52
|
+
end
|
53
|
+
|
54
|
+
def src_to_file_size(src)
|
55
|
+
# We're kind of intentionally only looking at {{ 'asset' | asset_url }} or full urls in here.
|
56
|
+
# More complicated liquid statements are not in scope.
|
57
|
+
if src =~ /^#{VARIABLE}$/o && src =~ /asset_url/ && src =~ Liquid::QuotedString
|
58
|
+
asset_id = Regexp.last_match(0).gsub(START_OR_END_QUOTE, "")
|
59
|
+
asset = @theme.assets.find { |a| a.name.end_with?("/" + asset_id) }
|
60
|
+
return if asset.nil?
|
61
|
+
asset.gzipped_size
|
62
|
+
elsif src =~ %r{^(https?:)?//}
|
63
|
+
asset = RemoteAssetFile.from_src(src)
|
64
|
+
asset.gzipped_size
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -4,7 +4,7 @@ module ThemeCheck
|
|
4
4
|
class ConvertIncludeToRender < LiquidCheck
|
5
5
|
severity :suggestion
|
6
6
|
category :liquid
|
7
|
-
doc
|
7
|
+
doc docs_url(__FILE__)
|
8
8
|
|
9
9
|
def on_include(node)
|
10
10
|
add_offense("`include` is deprecated - convert it to `render`", node: node)
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module ThemeCheck
|
3
|
+
# Reports errors when trying to use parser-blocking script tags
|
4
|
+
class ImgWidthAndHeight < LiquidCheck
|
5
|
+
include RegexHelpers
|
6
|
+
severity :error
|
7
|
+
categories :liquid, :performance
|
8
|
+
doc docs_url(__FILE__)
|
9
|
+
|
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
|
+
ENDS_IN_CSS_UNIT = /(cm|mm|in|px|pt|pc|em|ex|ch|rem|vw|vh|vmin|vmax|%)$/i
|
18
|
+
|
19
|
+
def on_document(node)
|
20
|
+
@source = node.template.source
|
21
|
+
@node = node
|
22
|
+
record_offenses
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
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
|
39
|
+
missing_width = width.nil?
|
40
|
+
missing_height = height.nil?
|
41
|
+
error_message = if missing_width && missing_height
|
42
|
+
"Missing width and height attributes"
|
43
|
+
elsif missing_width
|
44
|
+
"Missing width attribute"
|
45
|
+
elsif missing_height
|
46
|
+
"Missing height attribute"
|
47
|
+
end
|
48
|
+
|
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
|
+
)
|
55
|
+
end
|
56
|
+
|
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
|
72
|
+
end
|
73
|
+
end
|
74
|
+
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 4 or more consecutive {% ... %} are found.
|
4
4
|
class LiquidTag < LiquidCheck
|
5
5
|
severity :suggestion
|
6
6
|
category :liquid
|
7
|
-
doc
|
7
|
+
doc docs_url(__FILE__)
|
8
8
|
|
9
|
-
def initialize(min_consecutive_statements:
|
9
|
+
def initialize(min_consecutive_statements: 4)
|
10
10
|
@first_statement = nil
|
11
11
|
@consecutive_statements = 0
|
12
12
|
@min_consecutive_statements = min_consecutive_statements
|
@@ -4,13 +4,14 @@ module ThemeCheck
|
|
4
4
|
class MatchingTranslations < JsonCheck
|
5
5
|
severity :suggestion
|
6
6
|
category :translation
|
7
|
+
doc docs_url(__FILE__)
|
7
8
|
|
8
9
|
def initialize
|
9
10
|
@files = []
|
10
11
|
end
|
11
12
|
|
12
13
|
def on_file(file)
|
13
|
-
return unless file.name.
|
14
|
+
return unless file.name.start_with?("locales/")
|
14
15
|
return unless file.content.is_a?(Hash)
|
15
16
|
return if file.name == @theme.default_locale_json&.name
|
16
17
|
|
@@ -3,11 +3,10 @@
|
|
3
3
|
module ThemeCheck
|
4
4
|
# Reports missing shopify required theme files
|
5
5
|
# required templates: https://shopify.dev/tutorials/review-theme-store-requirements-files
|
6
|
-
|
7
6
|
class MissingRequiredTemplateFiles < LiquidCheck
|
8
7
|
severity :error
|
9
8
|
category :liquid
|
10
|
-
doc
|
9
|
+
doc docs_url(__FILE__)
|
11
10
|
|
12
11
|
REQUIRED_LIQUID_FILES = %w(layout/theme)
|
13
12
|
REQUIRED_TEMPLATE_FILES = %w(
|
@@ -2,14 +2,16 @@
|
|
2
2
|
module ThemeCheck
|
3
3
|
# Reports errors when trying to use parser-blocking script tags
|
4
4
|
class ParserBlockingJavaScript < LiquidCheck
|
5
|
+
include RegexHelpers
|
5
6
|
severity :error
|
6
|
-
|
7
|
+
categories :liquid, :performance
|
8
|
+
doc docs_url(__FILE__)
|
7
9
|
|
8
10
|
PARSER_BLOCKING_SCRIPT_TAG = %r{
|
9
11
|
<script # Find the start of a script tag
|
10
|
-
(?=
|
12
|
+
(?=[^>]+?src=) # Make sure src= is in the script with a lookahead
|
11
13
|
(?:(?!defer|async|type=["']module['"]).)*? # Find tags that don't have defer|async|type="module"
|
12
|
-
|
14
|
+
/?>
|
13
15
|
}xim
|
14
16
|
SCRIPT_TAG_FILTER = /\{\{[^}]+script_tag\s+\}\}/
|
15
17
|
|
@@ -32,23 +34,14 @@ module ThemeCheck
|
|
32
34
|
)
|
33
35
|
end
|
34
36
|
|
35
|
-
# The trickiness here is matching on scripts that are defined on
|
36
|
-
# multiple lines (or repeat matches). This makes the line_number
|
37
|
-
# calculation a bit weird. So instead, we traverse the string in
|
38
|
-
# a very imperative way.
|
39
37
|
def record_offenses_from_regex(regex: nil, message: nil)
|
40
|
-
|
41
|
-
while (i = @source.index(regex, i))
|
42
|
-
script = @source.match(regex, i)[0]
|
43
|
-
|
38
|
+
matches(@source, regex).each do |match|
|
44
39
|
add_offense(
|
45
40
|
message,
|
46
41
|
node: @node,
|
47
|
-
markup:
|
48
|
-
line_number: @source[0...
|
42
|
+
markup: match[0],
|
43
|
+
line_number: @source[0...match.begin(0)].count("\n") + 1
|
49
44
|
)
|
50
|
-
|
51
|
-
i += script.size
|
52
45
|
end
|
53
46
|
end
|
54
47
|
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module ThemeCheck
|
3
|
+
class RemoteAsset < LiquidCheck
|
4
|
+
include RegexHelpers
|
5
|
+
severity :suggestion
|
6
|
+
categories :liquid, :performance
|
7
|
+
doc docs_url(__FILE__)
|
8
|
+
|
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 = /<(?<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
|
30
|
+
PROTOCOL = %r{(https?:)?//}
|
31
|
+
ABSOLUTE_PATH = %r{\A/[^/]}im
|
32
|
+
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
|
+
|
39
|
+
def on_document(node)
|
40
|
+
source = node.template.source
|
41
|
+
record_html_offenses(node, source)
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
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
|
51
|
+
|
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
|
56
|
+
|
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) }
|
60
|
+
end
|
61
|
+
|
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
|
+
|
80
|
+
start = match.begin(0) + resource_match.begin(:resource_url)
|
81
|
+
add_offense(
|
82
|
+
OFFENSE_MESSAGE,
|
83
|
+
node: node,
|
84
|
+
markup: resource_url,
|
85
|
+
line_number: source[0...start].count("\n") + 1,
|
86
|
+
)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def non_stylesheet_link?(tag)
|
91
|
+
tag =~ REL && !(Regexp.last_match[:rel] =~ /\A['"]stylesheet['"]\Z/)
|
92
|
+
end
|
93
|
+
|
94
|
+
def url_hosted_by_shopify?(url)
|
95
|
+
url =~ /\A#{VARIABLE}\Z/oim && url =~ ASSET_URL_FILTER
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -5,7 +5,7 @@ module ThemeCheck
|
|
5
5
|
class RequiredDirectories < LiquidCheck
|
6
6
|
severity :error
|
7
7
|
category :liquid
|
8
|
-
doc
|
8
|
+
doc docs_url(__FILE__)
|
9
9
|
|
10
10
|
REQUIRED_DIRECTORIES = %w(assets config layout locales sections snippets templates)
|
11
11
|
|