theme-check 0.5.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +6 -3
  3. data/CHANGELOG.md +11 -0
  4. data/Gemfile +5 -3
  5. data/README.md +2 -0
  6. data/config/default.yml +8 -1
  7. data/data/shopify_translation_keys.yml +850 -0
  8. data/docs/checks/asset_size_css.md +52 -0
  9. data/docs/checks/img_width_and_height.md +79 -0
  10. data/docs/checks/parser_blocking_javascript.md +3 -3
  11. data/lib/theme_check/checks/asset_size_css.rb +89 -0
  12. data/lib/theme_check/checks/asset_size_javascript.rb +1 -7
  13. data/lib/theme_check/checks/img_width_and_height.rb +74 -0
  14. data/lib/theme_check/checks/parser_blocking_javascript.rb +5 -13
  15. data/lib/theme_check/checks/translation_key_exists.rb +16 -1
  16. data/lib/theme_check/cli.rb +19 -8
  17. data/lib/theme_check/config.rb +3 -0
  18. data/lib/theme_check/in_memory_storage.rb +11 -3
  19. data/lib/theme_check/language_server.rb +2 -0
  20. data/lib/theme_check/language_server/completion_engine.rb +1 -1
  21. data/lib/theme_check/language_server/completion_provider.rb +4 -0
  22. data/lib/theme_check/language_server/completion_providers/filter_completion_provider.rb +5 -1
  23. data/lib/theme_check/language_server/completion_providers/render_snippet_completion_provider.rb +43 -0
  24. data/lib/theme_check/language_server/constants.rb +10 -0
  25. data/lib/theme_check/language_server/document_link_engine.rb +47 -0
  26. data/lib/theme_check/language_server/handler.rb +33 -2
  27. data/lib/theme_check/language_server/server.rb +3 -2
  28. data/lib/theme_check/liquid_check.rb +11 -0
  29. data/lib/theme_check/remote_asset_file.rb +1 -1
  30. data/lib/theme_check/shopify_liquid/deprecated_filter.rb +4 -0
  31. data/lib/theme_check/version.rb +1 -1
  32. metadata +10 -2
@@ -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,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.ends_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
@@ -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 = /<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.present? && height.present?
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
@@ -2,13 +2,14 @@
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
7
8
  doc docs_url(__FILE__)
8
9
 
9
10
  PARSER_BLOCKING_SCRIPT_TAG = %r{
10
11
  <script # Find the start of a script tag
11
- (?=(?:[^>]|\n|\r)+?src=)+? # Make sure src= is in the script with a lookahead
12
+ (?=[^>]+?src=) # Make sure src= is in the script with a lookahead
12
13
  (?:(?!defer|async|type=["']module['"]).)*? # Find tags that don't have defer|async|type="module"
13
14
  >
14
15
  }xim
@@ -33,23 +34,14 @@ module ThemeCheck
33
34
  )
34
35
  end
35
36
 
36
- # The trickiness here is matching on scripts that are defined on
37
- # multiple lines (or repeat matches). This makes the line_number
38
- # calculation a bit weird. So instead, we traverse the string in
39
- # a very imperative way.
40
37
  def record_offenses_from_regex(regex: nil, message: nil)
41
- i = 0
42
- while (i = @source.index(regex, i))
43
- script = @source.match(regex, i)[0]
44
-
38
+ matches(@source, regex).each do |match|
45
39
  add_offense(
46
40
  message,
47
41
  node: @node,
48
- markup: script,
49
- line_number: @source[0...i].count("\n") + 1
42
+ markup: match[0],
43
+ line_number: @source[0...match.begin(0)].count("\n") + 1
50
44
  )
51
-
52
- i += script.size
53
45
  end
54
46
  end
55
47
  end
@@ -1,5 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
  module ThemeCheck
3
+ module SystemTranslations
4
+ extend self
5
+
6
+ def translations
7
+ @translations ||= begin
8
+ # loaded as a Set because the include? lookup will be much faster.
9
+ YAML.load(File.read("#{__dir__}/../../../data/shopify_translation_keys.yml")).to_set
10
+ end
11
+ end
12
+
13
+ def include?(key)
14
+ translations.include?(key)
15
+ end
16
+ end
17
+
3
18
  class TranslationKeyExists < LiquidCheck
4
19
  severity :error
5
20
  category :translation
@@ -12,7 +27,7 @@ module ThemeCheck
12
27
  return unless (key_node = node.children.first)
13
28
  return unless key_node.value.is_a?(String)
14
29
 
15
- unless key_exists?(key_node.value)
30
+ unless key_exists?(key_node.value) || SystemTranslations.include?(key_node.value)
16
31
  add_offense(
17
32
  "'#{key_node.value}' does not have a matching entry in '#{@theme.default_locale_json.relative_path}'",
18
33
  node: node,
@@ -7,13 +7,14 @@ module ThemeCheck
7
7
  Usage: theme-check [options] /path/to/your/theme
8
8
 
9
9
  Options:
10
- -c, [--category] # Only run this category of checks
11
- -x, [--exclude-category] # Exclude this category of checks
12
- -l, [--list] # List enabled checks
13
- -a, [--auto-correct] # Automatically fix offenses
14
- --init # Generate a .theme-check.yml file in the current directory
15
- -h, [--help] # Show this. Hi!
16
- -v, [--version] # Print Theme Check version
10
+ --init Generate a .theme-check.yml file in the current directory
11
+ -C, --config <path> Use the config provided, overriding .theme-check.yml if present
12
+ -c, --category <category> Only run this category of checks
13
+ -x, --exclude-category <category> Exclude this category of checks
14
+ -l, --list List enabled checks
15
+ -a, --auto-correct Automatically fix offenses
16
+ -h, --help Show this. Hi!
17
+ -v, --version Print Theme Check version
17
18
 
18
19
  Description:
19
20
  Theme Check helps you follow Shopify Themes & Liquid best practices by analyzing the
@@ -29,6 +30,7 @@ module ThemeCheck
29
30
  only_categories = []
30
31
  exclude_categories = []
31
32
  auto_correct = false
33
+ config_path = nil
32
34
 
33
35
  args = argv.dup
34
36
  while (arg = args.shift)
@@ -37,6 +39,8 @@ module ThemeCheck
37
39
  raise Abort, USAGE
38
40
  when "--version", "-v"
39
41
  command = :version
42
+ when "--config", "-C"
43
+ config_path = Pathname.new(args.shift)
40
44
  when "--category", "-c"
41
45
  only_categories << args.shift.to_sym
42
46
  when "--exclude-category", "-x"
@@ -53,7 +57,14 @@ module ThemeCheck
53
57
  end
54
58
 
55
59
  unless [:version, :init].include?(command)
56
- @config = ThemeCheck::Config.from_path(@path)
60
+ @config = if config_path.present?
61
+ ThemeCheck::Config.new(
62
+ root: @path,
63
+ configuration: ThemeCheck::Config.load_file(config_path)
64
+ )
65
+ else
66
+ ThemeCheck::Config.from_path(@path)
67
+ end
57
68
  @config.only_categories = only_categories
58
69
  @config.exclude_categories = exclude_categories
59
70
  @config.auto_correct = auto_correct