theme-check 0.5.0 → 0.7.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +10 -3
  3. data/.rubocop.yml +6 -3
  4. data/CHANGELOG.md +35 -0
  5. data/Gemfile +5 -3
  6. data/LICENSE.md +2 -0
  7. data/README.md +3 -0
  8. data/RELEASING.md +10 -3
  9. data/Rakefile +6 -0
  10. data/config/default.yml +11 -1
  11. data/data/shopify_translation_keys.yml +850 -0
  12. data/docs/checks/asset_size_css.md +52 -0
  13. data/docs/checks/img_width_and_height.md +79 -0
  14. data/docs/checks/parser_blocking_javascript.md +3 -3
  15. data/docs/checks/remote_asset.md +82 -0
  16. data/exe/theme-check +1 -1
  17. data/lib/theme_check.rb +1 -0
  18. data/lib/theme_check/check.rb +1 -1
  19. data/lib/theme_check/checks/asset_size_css.rb +89 -0
  20. data/lib/theme_check/checks/asset_size_javascript.rb +2 -8
  21. data/lib/theme_check/checks/img_width_and_height.rb +74 -0
  22. data/lib/theme_check/checks/matching_translations.rb +1 -1
  23. data/lib/theme_check/checks/parser_blocking_javascript.rb +6 -14
  24. data/lib/theme_check/checks/remote_asset.rb +99 -0
  25. data/lib/theme_check/checks/translation_key_exists.rb +13 -1
  26. data/lib/theme_check/checks/undefined_object.rb +1 -1
  27. data/lib/theme_check/checks/valid_html_translation.rb +1 -1
  28. data/lib/theme_check/cli.rb +106 -51
  29. data/lib/theme_check/config.rb +3 -0
  30. data/lib/theme_check/disabled_checks.rb +2 -2
  31. data/lib/theme_check/in_memory_storage.rb +13 -8
  32. data/lib/theme_check/language_server.rb +2 -0
  33. data/lib/theme_check/language_server/completion_engine.rb +3 -3
  34. data/lib/theme_check/language_server/completion_provider.rb +4 -0
  35. data/lib/theme_check/language_server/completion_providers/filter_completion_provider.rb +6 -2
  36. data/lib/theme_check/language_server/completion_providers/object_completion_provider.rb +1 -1
  37. data/lib/theme_check/language_server/completion_providers/render_snippet_completion_provider.rb +43 -0
  38. data/lib/theme_check/language_server/completion_providers/tag_completion_provider.rb +2 -2
  39. data/lib/theme_check/language_server/constants.rb +10 -0
  40. data/lib/theme_check/language_server/document_link_engine.rb +48 -0
  41. data/lib/theme_check/language_server/handler.rb +56 -17
  42. data/lib/theme_check/language_server/server.rb +4 -4
  43. data/lib/theme_check/liquid_check.rb +11 -0
  44. data/lib/theme_check/node.rb +1 -2
  45. data/lib/theme_check/offense.rb +3 -1
  46. data/lib/theme_check/packager.rb +1 -1
  47. data/lib/theme_check/releaser.rb +39 -0
  48. data/lib/theme_check/remote_asset_file.rb +1 -1
  49. data/lib/theme_check/shopify_liquid/deprecated_filter.rb +10 -8
  50. data/lib/theme_check/shopify_liquid/filter.rb +3 -5
  51. data/lib/theme_check/shopify_liquid/object.rb +2 -6
  52. data/lib/theme_check/shopify_liquid/tag.rb +1 -3
  53. data/lib/theme_check/storage.rb +3 -3
  54. data/lib/theme_check/string_helpers.rb +47 -0
  55. data/lib/theme_check/tags.rb +1 -2
  56. data/lib/theme_check/theme.rb +1 -1
  57. data/lib/theme_check/version.rb +1 -1
  58. data/packaging/homebrew/theme_check.base.rb +1 -1
  59. data/theme-check.gemspec +1 -2
  60. metadata +16 -18
@@ -0,0 +1,52 @@
1
+ # Prevent Large CSS bundles (`AssetSizeCSS`)
2
+
3
+ This rule exists to prevent large CSS bundles (for speed).
4
+
5
+ ## Check Details
6
+
7
+ This rule disallows the use of too much CSS in themes, as configured by `threshold_in_bytes`.
8
+
9
+ :-1: Examples of **incorrect** code for this check:
10
+ ```liquid
11
+ <!-- Here, assets/theme.css is **greater** than `threshold_in_bytes` compressed. -->
12
+ {{ 'theme.css' | asset_url | stylesheet_tag }}
13
+ ```
14
+
15
+ :+1: Example of **correct** code for this check:
16
+ ```liquid
17
+ <!-- Here, assets/theme.css is **less** than `threshold_in_bytes` compressed. -->
18
+ {{ 'theme.css' | asset_url | stylesheet_tag }}
19
+ ```
20
+
21
+ ## Check Options
22
+
23
+ The default configuration is the following.
24
+
25
+ ```yaml
26
+ AssetSizeCSS:
27
+ enabled: false
28
+ threshold_in_bytes: 100_000
29
+ ```
30
+
31
+ ### `threshold_in_bytes`
32
+
33
+ The `threshold_in_bytes` option (default: `100_000`) determines the maximum allowed compressed size in bytes that a single CSS file can take.
34
+
35
+ This includes theme and remote stylesheets.
36
+
37
+ ## When Not To Use It
38
+
39
+ This rule is safe to disable.
40
+
41
+ ## Version
42
+
43
+ This check has been introduced in Theme Check 0.6.0.
44
+
45
+ ## Resources
46
+
47
+ - [The Performance Inequality Gap](https://infrequently.org/2021/03/the-performance-inequality-gap/)
48
+ - [Rule Source][codesource]
49
+ - [Documentation Source][docsource]
50
+
51
+ [codesource]: /lib/theme_check/checks/asset_size_css.rb
52
+ [docsource]: /docs/checks/asset_size_css.md
@@ -0,0 +1,79 @@
1
+ # Width and height attributes on image tags (`ImgWidthAndHeight`)
2
+
3
+ This check exists to prevent [cumulative layout shift][cls] (CLS) in themes.
4
+
5
+ The absence of `width` and `height` attributes on an `img` tag prevents the browser from knowing the aspect ratio of the image before it is downloaded. Unless another technique is used to allocate space, the browser will consider the image to be of height 0 until it is loaded.
6
+
7
+ This has numerous nefarious implications:
8
+
9
+ 1. [This causes layout shift as images start appearing one after the other.][codepenshift] Text starts flying down the page as the image pushes it down.
10
+ 2. [This breaks lazy loading.][codepenlazy] When all images have a height of 0px, every image is inside the viewport. And when everything is in the viewport, everything gets loaded. There's nothing lazy about it!
11
+
12
+ The fix is easy. Make sure the `width` and `height` attribute are set on the `img` tag and that the CSS width of the image is set.
13
+
14
+ Note: The width and height attributes of an image do not have units.
15
+
16
+ ## Check Details
17
+
18
+ This check is aimed at eliminating content layout shift in themes by enforcing the use of the `width` and `height` attributes on `img` tags.
19
+
20
+ :-1: Examples of **incorrect** code for this check:
21
+
22
+ ```liquid
23
+ <img alt="cat" src="cat.jpg">
24
+ <img alt="cat" src="cat.jpg" width="100px" height="100px">
25
+ <img alt="{{ image.alt }}" src="{{ image.src }}">
26
+ ```
27
+
28
+ :+1: Examples of **correct** code for this check:
29
+
30
+ ```liquid
31
+ <img alt="cat" src="cat.jpg" width="100" height="200">
32
+ <img
33
+ alt="{{ image.alt }}"
34
+ src="{{ image.src }}"
35
+ width="{{ image.width }}"
36
+ height="{{ image.height }}"
37
+ >
38
+ ```
39
+
40
+ **NOTE:** The CSS `width` of the `img` should _also_ be set for the image to be responsive.
41
+
42
+ ## Check Options
43
+
44
+ The default configuration for this check is the following:
45
+
46
+ ```yaml
47
+ ImgWidthAndHeight:
48
+ enabled: true
49
+ ```
50
+
51
+ ## When Not To Use It
52
+
53
+ There are some cases where you can avoid content-layout shift without needing the width and height attributes:
54
+
55
+ - When the aspect-ratio of the displayed image should be independent of the uploaded image. In those cases, the solution is still the padding-top hack with an `overflow: hidden container`.
56
+ - When you are happy with the padding-top hack.
57
+
58
+ In those cases, it is fine to disable this check with the comment.
59
+
60
+ It is otherwise unwise to disable this check, since it would negatively impact the mobile search ranking of the merchants using your theme.
61
+
62
+ ## Version
63
+
64
+ This check has been introduced in Theme Check 0.6.0.
65
+
66
+ ## Resources
67
+
68
+ - [Cumulative Layout Shift Reference][cls]
69
+ - [Codepen illustrating the impact of width and height on layout shift][codepenshift]
70
+ - [Codepen illustrating the impact of width and height on lazy loading][codepenlazy]
71
+ - [Rule Source][codesource]
72
+ - [Documentation Source][docsource]
73
+
74
+ [cls]: https://web.dev/cls/
75
+ [codepenshift]: https://codepen.io/charlespwd/pen/YzpxPEp?editors=1100
76
+ [codepenlazy]: https://codepen.io/charlespwd/pen/abZmqXJ?editors=0111
77
+ [aspect-ratio]: https://caniuse.com/mdn-css_properties_aspect-ratio
78
+ [codesource]: /lib/theme_check/checks/img_aspect_ratio.rb
79
+ [docsource]: /docs/checks/img_aspect_ratio.md
@@ -31,13 +31,13 @@ This check is aimed at eliminating parser-blocking JavaScript on themes.
31
31
 
32
32
  ```liquid
33
33
  <!-- Good. Using the asset_url filter + defer -->
34
- <script src="{{ 'theme.js' | asset_url }}" defer><script>
34
+ <script src="{{ 'theme.js' | asset_url }}" defer></script>
35
35
 
36
36
  <!-- Also good. Using the asset_url filter + async -->
37
- <script src="{{ 'theme.js' | asset_url }}" async><script>
37
+ <script src="{{ 'theme.js' | asset_url }}" async></script>
38
38
 
39
39
  <!-- Better than synchronous jQuery -->
40
- <script src="https://code.jquery.com/jquery-3.6.0.min.js" defer><script>
40
+ <script src="https://code.jquery.com/jquery-3.6.0.min.js" defer></script>
41
41
  ...
42
42
  <button id="thing">Click me!</button>
43
43
  <script>
@@ -0,0 +1,82 @@
1
+ # Discourage use of third party domains for hosting assets (`RemoteAsset`)
2
+
3
+ Years ago, loading jQuery from a common CDN was good for performance because the browser cache could be reused across website. This is no longer true because browsers now include the domain from which the request was made in the cache key.
4
+
5
+ Therefore, this technique now makes things worse. Here's why:
6
+
7
+ * **The benefits of HTTP/2 prioritization are lost.** HTTP/2 prioritization is a mechanism used by servers. If different servers are used to deliver assets, there's no way to prioritize.
8
+ * **A new connection dance (DNS, TCP, TLS) must be done to start downloading the resource.** With HTTPS, this takes 5 round trips to achieve. The farther away the buyer is from that domain, the longer it takes.
9
+ * **The [slow start][slowstart] part of the Internet's TCP congestion control strategy must happen on every connection.** This means that the download "acceleration" we commonly observe must be repeated many times over.
10
+
11
+ The fix? Deliver as much as you can from a small number of connections. In a Shopify context, this is done by leveraging the `assets/` folder and the [URL filters][url_filters].
12
+
13
+ ## Check Details
14
+
15
+ This check is aimed at eliminating unnecessary HTTP connections.
16
+
17
+ :-1: Examples of **incorrect** code for this check:
18
+
19
+ ```liquid
20
+ <!-- Using multiple CDNs -->
21
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js" defer></script>
22
+ {{ "https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" | stylesheet_tag }}
23
+ <img src="https://example.com/heart.png" ...>
24
+
25
+ <!-- Missing img_url filter -->
26
+ <img src="{{ image }}" ...>
27
+ ```
28
+
29
+ In the examples above, multiple connections are competing for resources, are accelerating download independently and are improperly prioritized.
30
+
31
+ :+1: Examples of **correct** code for this check:
32
+
33
+ ```liquid
34
+ <!-- Good -->
35
+ <script src="{{ 'jquery.min.js' | asset_url }}" defer></script>
36
+ {{ 'bootstrap.min.css' | asset_url | stylesheet_tag }}
37
+
38
+ <!-- Better -->
39
+ <script src="{{ 'theme.js' | asset_url }}" defer></script>
40
+ {{ 'theme.css' | asset_url | stylesheet_tag }}
41
+
42
+ <!-- Images -->
43
+ <img src="{{ image | img_url }}" ...>
44
+ ```
45
+
46
+ In the above, the JavaScript, CSS and images are all loading from the same connection. Making it so the browser and CDN can properly prioritize which assets are downloaded first while maintaining a "hot" connection that downloads fast.
47
+
48
+ This can be done by downloading the files from those CDNs directly into your theme's `assets/` folder.
49
+
50
+ Use the [`img_url` filter][img_url] for images.
51
+
52
+ ## Check Options
53
+
54
+ The default configuration for this check is the following:
55
+
56
+ ```yaml
57
+ RemoteAsset:
58
+ enabled: true
59
+ ```
60
+
61
+ ## When Not To Use It
62
+
63
+ When the remote content is highly dynamic.
64
+
65
+ ## Version
66
+
67
+ This check has been introduced in Theme Check 0.7.0.
68
+
69
+ ## Resources
70
+
71
+ - [Announcement by Google][googleprivacy]
72
+ - [HTTP Cache Partioning Explainer](https://github.com/shivanigithub/http-cache-partitioning)
73
+ - [Slow Start][slowstart]
74
+ - [Rule Source][codesource]
75
+ - [Documentation Source][docsource]
76
+
77
+ [googleprivacy]: https://developers.google.com/web/updates/2020/10/http-cache-partitioning#resources
78
+ [codesource]: /lib/theme_check/checks/remote_asset.rb
79
+ [docsource]: /docs/checks/remote_asset.md
80
+ [slowstart]: https://en.wikipedia.org/wiki/TCP_congestion_control#Slow_start
81
+ [url_filters]: https://shopify.dev/docs/themes/liquid/reference/filters/url-filters
82
+ [img_url]: https://shopify.dev/docs/themes/liquid/reference/filters/url-filters#img_url
data/exe/theme-check CHANGED
@@ -3,4 +3,4 @@
3
3
 
4
4
  require "theme_check"
5
5
 
6
- ThemeCheck::Cli.new.run!(ARGV)
6
+ ThemeCheck::Cli.parse_and_run(ARGV)
data/lib/theme_check.rb CHANGED
@@ -22,6 +22,7 @@ require_relative "theme_check/offense"
22
22
  require_relative "theme_check/printer"
23
23
  require_relative "theme_check/shopify_liquid"
24
24
  require_relative "theme_check/storage"
25
+ require_relative "theme_check/string_helpers"
25
26
  require_relative "theme_check/file_system_storage"
26
27
  require_relative "theme_check/in_memory_storage"
27
28
  require_relative "theme_check/tags"
@@ -82,7 +82,7 @@ module ThemeCheck
82
82
  end
83
83
 
84
84
  def code_name
85
- self.class.name.demodulize
85
+ StringHelpers.demodulize(self.class.name)
86
86
  end
87
87
 
88
88
  def ignore!
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class AssetSizeCSS < LiquidCheck
4
+ include RegexHelpers
5
+ severity :error
6
+ category :performance
7
+ doc docs_url(__FILE__)
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
+ attr_reader :threshold_in_bytes
29
+
30
+ def initialize(threshold_in_bytes: 100_000)
31
+ @threshold_in_bytes = threshold_in_bytes
32
+ end
33
+
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
87
+ end
88
+ end
89
+ end
@@ -11,17 +11,11 @@ module ThemeCheck
11
11
 
12
12
  Script = Struct.new(:src, :match)
13
13
 
14
- TAG = /#{Liquid::TagStart}.*?#{Liquid::TagEnd}/om
15
- VARIABLE = /#{Liquid::VariableStart}.*?#{Liquid::VariableEnd}/om
16
- START_OR_END_QUOTE = /(^['"])|(['"]$)/
17
14
  SCRIPT_TAG_SRC = %r{
18
15
  <script
19
16
  [^>]+ # any non closing tag character
20
17
  src= # src attribute start
21
- (?<src>
22
- '(?:#{TAG}|#{VARIABLE}|[^']+)*'| # any combination of tag/variable or non straight quote inside straight quotes
23
- "(?:#{TAG}|#{VARIABLE}|[^"]+)*" # any combination of tag/variable or non double quotes inside double quotes
24
- )
18
+ (?<src>#{QUOTED_LIQUID_ATTRIBUTE}) # src attribute value (may contain liquid)
25
19
  [^>]* # any non closing character till the end
26
20
  >
27
21
  }omix
@@ -62,7 +56,7 @@ module ThemeCheck
62
56
  # More complicated liquid statements are not in scope.
63
57
  if src =~ /^#{VARIABLE}$/o && src =~ /asset_url/ && src =~ Liquid::QuotedString
64
58
  asset_id = Regexp.last_match(0).gsub(START_OR_END_QUOTE, "")
65
- asset = @theme.assets.find { |a| a.name.ends_with?("/" + asset_id) }
59
+ asset = @theme.assets.find { |a| a.name.end_with?("/" + asset_id) }
66
60
  return if asset.nil?
67
61
  asset.gzipped_size
68
62
  elsif src =~ %r{^(https?:)?//}
@@ -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