theme-check 0.5.0 → 0.7.3

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.
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