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
@@ -11,7 +11,7 @@ module ThemeCheck
11
11
  end
12
12
 
13
13
  def on_file(file)
14
- return unless file.name.starts_with?("locales/")
14
+ return unless file.name.start_with?("locales/")
15
15
  return unless file.content.is_a?(Hash)
16
16
  return if file.name == @theme.default_locale_json&.name
17
17
 
@@ -2,15 +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
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
15
16
  SCRIPT_TAG_FILTER = /\{\{[^}]+script_tag\s+\}\}/
16
17
 
@@ -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
@@ -0,0 +1,99 @@
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 = %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
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
+ 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
94
+
95
+ def url_hosted_by_shopify?(url)
96
+ url =~ /\A#{VARIABLE}\Z/oim && url =~ ASSET_URL_FILTER
97
+ end
98
+ end
99
+ end
@@ -1,5 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
  module ThemeCheck
3
+ module SystemTranslations
4
+ extend self
5
+
6
+ def translations
7
+ @translations ||= YAML.load(File.read("#{__dir__}/../../../data/shopify_translation_keys.yml")).to_set
8
+ end
9
+
10
+ def include?(key)
11
+ translations.include?(key)
12
+ end
13
+ end
14
+
3
15
  class TranslationKeyExists < LiquidCheck
4
16
  severity :error
5
17
  category :translation
@@ -12,7 +24,7 @@ module ThemeCheck
12
24
  return unless (key_node = node.children.first)
13
25
  return unless key_node.value.is_a?(String)
14
26
 
15
- unless key_exists?(key_node.value)
27
+ unless key_exists?(key_node.value) || SystemTranslations.include?(key_node.value)
16
28
  add_offense(
17
29
  "'#{key_node.value}' does not have a matching entry in '#{@theme.default_locale_json.relative_path}'",
18
30
  node: node,
@@ -135,7 +135,7 @@ module ThemeCheck
135
135
 
136
136
  def each_template
137
137
  @files.each do |(name, info)|
138
- next if name.starts_with?('snippets/')
138
+ next if name.start_with?('snippets/')
139
139
  yield [name, info]
140
140
  end
141
141
  end
@@ -8,7 +8,7 @@ module ThemeCheck
8
8
  doc docs_url(__FILE__)
9
9
 
10
10
  def on_file(file)
11
- return unless file.name.starts_with?("locales/")
11
+ return unless file.name.start_with?("locales/")
12
12
  return unless file.content.is_a?(Hash)
13
13
 
14
14
  visit_nested(file.content)
@@ -1,69 +1,104 @@
1
1
  # frozen_string_literal: true
2
+ require "optparse"
3
+
2
4
  module ThemeCheck
3
5
  class Cli
4
6
  class Abort < StandardError; end
5
7
 
6
- USAGE = <<~END
7
- Usage: theme-check [options] /path/to/your/theme
8
+ attr_accessor :path
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
+ def initialize
11
+ @path = "."
12
+ @command = :check
13
+ @only_categories = []
14
+ @exclude_categories = []
15
+ @auto_correct = false
16
+ @config_path = nil
17
+ end
17
18
 
18
- Description:
19
- Theme Check helps you follow Shopify Themes & Liquid best practices by analyzing the
20
- Liquid & JSON inside your theme.
19
+ def option_parser(parser = OptionParser.new, help: true)
20
+ return @option_parser if @option_parser
21
+ @option_parser = parser
22
+ @option_parser.banner = "Usage: theme-check [options] [/path/to/your/theme]"
21
23
 
22
- You can configure checks in the .theme-check.yml file of your theme root directory.
23
- END
24
+ @option_parser.separator("")
25
+ @option_parser.separator("Basic Options:")
26
+ @option_parser.on(
27
+ "-C", "--config PATH",
28
+ "Use the config provided, overriding .theme-check.yml if present"
29
+ ) { |path| @config_path = path }
30
+ @option_parser.on(
31
+ "-c", "--category CATEGORY",
32
+ "Only run this category of checks"
33
+ ) { |category| @only_categories << category.to_sym }
34
+ @option_parser.on(
35
+ "-x", "--exclude-category CATEGORY",
36
+ "Exclude this category of checks"
37
+ ) { |category| @exclude_categories << category.to_sym }
38
+ @option_parser.on(
39
+ "-a", "--auto-correct",
40
+ "Automatically fix offenses"
41
+ ) { @auto_correct = true }
24
42
 
25
- def run(argv)
26
- @path = "."
43
+ @option_parser.separator("")
44
+ @option_parser.separator("Miscellaneous:")
45
+ @option_parser.on(
46
+ "--init",
47
+ "Generate a .theme-check.yml file"
48
+ ) { @command = :init }
49
+ @option_parser.on(
50
+ "--print",
51
+ "Output active config to STDOUT"
52
+ ) { @command = :print }
53
+ @option_parser.on(
54
+ "-h", "--help",
55
+ "Show this. Hi!"
56
+ ) { @command = :help } if help
57
+ @option_parser.on(
58
+ "-l", "--list",
59
+ "List enabled checks"
60
+ ) { @command = :list }
61
+ @option_parser.on(
62
+ "-v", "--version",
63
+ "Print Theme Check version"
64
+ ) { @command = :version }
65
+
66
+ @option_parser.separator("")
67
+ @option_parser.separator(<<~EOS)
68
+ Description:
69
+ Theme Check helps you follow Shopify Themes & Liquid best practices by analyzing the
70
+ Liquid & JSON inside your theme.
71
+
72
+ You can configure checks in the .theme-check.yml file of your theme root directory.
73
+ EOS
74
+
75
+ @option_parser
76
+ end
77
+
78
+ def parse(argv)
79
+ @path = option_parser.parse(argv).first || "."
80
+ end
27
81
 
28
- command = :check
29
- only_categories = []
30
- exclude_categories = []
31
- auto_correct = false
32
-
33
- args = argv.dup
34
- while (arg = args.shift)
35
- case arg
36
- when "--help", "-h"
37
- raise Abort, USAGE
38
- when "--version", "-v"
39
- command = :version
40
- when "--category", "-c"
41
- only_categories << args.shift.to_sym
42
- when "--exclude-category", "-x"
43
- exclude_categories << args.shift.to_sym
44
- when "--list", "-l"
45
- command = :list
46
- when "--auto-correct", "-a"
47
- auto_correct = true
48
- when "--init"
49
- command = :init
82
+ def run!
83
+ unless [:version, :init, :help].include?(@command)
84
+ @config = if @config_path
85
+ ThemeCheck::Config.new(
86
+ root: @path,
87
+ configuration: ThemeCheck::Config.load_file(@config_path)
88
+ )
50
89
  else
51
- @path = arg
90
+ ThemeCheck::Config.from_path(@path)
52
91
  end
92
+ @config.only_categories = @only_categories
93
+ @config.exclude_categories = @exclude_categories
94
+ @config.auto_correct = @auto_correct
53
95
  end
54
96
 
55
- unless [:version, :init].include?(command)
56
- @config = ThemeCheck::Config.from_path(@path)
57
- @config.only_categories = only_categories
58
- @config.exclude_categories = exclude_categories
59
- @config.auto_correct = auto_correct
60
- end
61
-
62
- send(command)
97
+ send(@command)
63
98
  end
64
99
 
65
- def run!(argv)
66
- run(argv)
100
+ def run
101
+ run!
67
102
  rescue Abort => e
68
103
  if e.message.empty?
69
104
  exit(1)
@@ -72,6 +107,18 @@ module ThemeCheck
72
107
  end
73
108
  end
74
109
 
110
+ def self.parse_and_run!(argv)
111
+ cli = new
112
+ cli.parse(argv)
113
+ cli.run!
114
+ end
115
+
116
+ def self.parse_and_run(argv)
117
+ cli = new
118
+ cli.parse(argv)
119
+ cli.run
120
+ end
121
+
75
122
  def list
76
123
  puts @config.enabled_checks
77
124
  end
@@ -91,12 +138,20 @@ module ThemeCheck
91
138
  end
92
139
  end
93
140
 
141
+ def print
142
+ puts YAML.dump(@config.to_h)
143
+ end
144
+
145
+ def help
146
+ puts option_parser.to_s
147
+ end
148
+
94
149
  def check
95
150
  puts "Checking #{@config.root} ..."
96
151
  storage = ThemeCheck::FileSystemStorage.new(@config.root, ignored_patterns: @config.ignored_patterns)
97
152
  theme = ThemeCheck::Theme.new(storage)
98
153
  if theme.all.empty?
99
- raise Abort, "No templates found.\n#{USAGE}"
154
+ raise Abort, "No templates found."
100
155
  end
101
156
  analyzer = ThemeCheck::Analyzer.new(theme, @config.enabled_checks, @config.auto_correct)
102
157
  analyzer.analyze_theme
@@ -10,6 +10,8 @@ module ThemeCheck
10
10
  attr_accessor :only_categories, :exclude_categories, :auto_correct
11
11
 
12
12
  class << self
13
+ attr_reader :last_loaded_config
14
+
13
15
  def from_path(path)
14
16
  if (filename = find(path))
15
17
  new(root: filename.dirname, configuration: load_file(filename))
@@ -36,6 +38,7 @@ module ThemeCheck
36
38
  end
37
39
 
38
40
  def load_file(absolute_path)
41
+ @last_loaded_config = absolute_path
39
42
  YAML.load_file(absolute_path)
40
43
  end
41
44
 
@@ -61,11 +61,11 @@ module ThemeCheck
61
61
  end
62
62
 
63
63
  def start_disabling?(text)
64
- text.strip.starts_with?(DISABLE_START)
64
+ text.strip.start_with?(DISABLE_START)
65
65
  end
66
66
 
67
67
  def stop_disabling?(text)
68
- text.strip.starts_with?(DISABLE_END)
68
+ text.strip.start_with?(DISABLE_END)
69
69
  end
70
70
 
71
71
  # Return a list of checks from a theme-check-disable comment
@@ -6,24 +6,25 @@
6
6
  # as a big hash already, leave it like that and save yourself some IO.
7
7
  module ThemeCheck
8
8
  class InMemoryStorage < Storage
9
- def initialize(files = {})
9
+ def initialize(files = {}, root = "/dev/null")
10
10
  @files = files
11
+ @root = Pathname.new(root)
11
12
  end
12
13
 
13
- def path(name)
14
- name
14
+ def path(relative_path)
15
+ @root.join(relative_path)
15
16
  end
16
17
 
17
- def read(name)
18
- @files[name]
18
+ def read(relative_path)
19
+ @files[relative_path]
19
20
  end
20
21
 
21
- def write(name, content)
22
- @files[name] = content
22
+ def write(relative_path, content)
23
+ @files[relative_path] = content
23
24
  end
24
25
 
25
26
  def files
26
- @values ||= @files.keys
27
+ @files.keys
27
28
  end
28
29
 
29
30
  def directories
@@ -33,5 +34,9 @@ module ThemeCheck
33
34
  .map(&:to_s)
34
35
  .uniq
35
36
  end
37
+
38
+ def relative_path(absolute_path)
39
+ Pathname.new(absolute_path).relative_path_from(@root).to_s
40
+ end
36
41
  end
37
42
  end