theme-check 1.3.0 → 1.5.2

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +3 -3
  3. data/.gitignore +1 -0
  4. data/CHANGELOG.md +38 -0
  5. data/CONTRIBUTING.md +58 -0
  6. data/Gemfile +3 -0
  7. data/config/default.yml +4 -1
  8. data/data/shopify_liquid/objects.yml +1 -0
  9. data/docs/checks/deprecate_lazysizes.md +0 -3
  10. data/docs/checks/deprecated_global_app_block_type.md +65 -0
  11. data/docs/checks/template_length.md +1 -1
  12. data/docs/flamegraph.svg +18488 -0
  13. data/lib/theme_check/analyzer.rb +1 -0
  14. data/lib/theme_check/checks/default_locale.rb +3 -1
  15. data/lib/theme_check/checks/deprecate_lazysizes.rb +6 -3
  16. data/lib/theme_check/checks/deprecated_global_app_block_type.rb +57 -0
  17. data/lib/theme_check/checks/liquid_tag.rb +1 -1
  18. data/lib/theme_check/checks/pagination_size.rb +33 -14
  19. data/lib/theme_check/checks/remote_asset.rb +2 -2
  20. data/lib/theme_check/checks/required_directories.rb +3 -1
  21. data/lib/theme_check/checks/space_inside_braces.rb +47 -24
  22. data/lib/theme_check/checks/template_length.rb +1 -1
  23. data/lib/theme_check/cli.rb +28 -5
  24. data/lib/theme_check/corrector.rb +9 -0
  25. data/lib/theme_check/file_system_storage.rb +6 -0
  26. data/lib/theme_check/in_memory_storage.rb +4 -0
  27. data/lib/theme_check/json_file.rb +11 -0
  28. data/lib/theme_check/json_printer.rb +6 -1
  29. data/lib/theme_check/language_server/constants.rb +18 -11
  30. data/lib/theme_check/language_server/document_link_engine.rb +3 -67
  31. data/lib/theme_check/language_server/document_link_provider.rb +71 -0
  32. data/lib/theme_check/language_server/document_link_providers/asset_document_link_provider.rb +11 -0
  33. data/lib/theme_check/language_server/document_link_providers/include_document_link_provider.rb +11 -0
  34. data/lib/theme_check/language_server/document_link_providers/render_document_link_provider.rb +11 -0
  35. data/lib/theme_check/language_server/document_link_providers/section_document_link_provider.rb +11 -0
  36. data/lib/theme_check/language_server/handler.rb +17 -13
  37. data/lib/theme_check/language_server/server.rb +11 -13
  38. data/lib/theme_check/language_server/uri_helper.rb +37 -0
  39. data/lib/theme_check/language_server.rb +6 -0
  40. data/lib/theme_check/node.rb +120 -8
  41. data/lib/theme_check/position.rb +27 -16
  42. data/lib/theme_check/position_helper.rb +13 -15
  43. data/lib/theme_check/printer.rb +9 -5
  44. data/lib/theme_check/remote_asset_file.rb +4 -0
  45. data/lib/theme_check/theme.rb +2 -1
  46. data/lib/theme_check/version.rb +1 -1
  47. metadata +11 -2
@@ -87,6 +87,7 @@ module ThemeCheck
87
87
  if @auto_correct
88
88
  offenses.each(&:correct)
89
89
  @theme.liquid.each(&:write)
90
+ @theme.json.each(&:write)
90
91
  end
91
92
  end
92
93
 
@@ -7,7 +7,9 @@ module ThemeCheck
7
7
 
8
8
  def on_end
9
9
  return if @theme.default_locale_json
10
- add_offense("Default translation file not found (for example locales/en.default.json)")
10
+ add_offense("Default translation file not found (for example locales/en.default.json)") do |corrector|
11
+ corrector.create_default_locale_json(@theme)
12
+ end
11
13
  end
12
14
  end
13
15
  end
@@ -7,10 +7,13 @@ module ThemeCheck
7
7
 
8
8
  def on_img(node)
9
9
  class_list = node.attributes["class"]&.split(" ")
10
+ has_loading_lazy = node.attributes["loading"] == "lazy"
11
+ has_native_source = node.attributes["src"] || node.attributes["srcset"]
12
+ return if has_native_source && has_loading_lazy
13
+ has_lazysize_source = node.attributes["data-srcset"] || node.attributes["data-src"]
14
+ has_lazysize_class = class_list&.include?("lazyload")
15
+ return unless has_lazysize_class && has_lazysize_source
10
16
  add_offense("Use the native loading=\"lazy\" attribute instead of lazysizes", node: node) if class_list&.include?("lazyload")
11
- add_offense("Use the native srcset attribute instead of data-srcset", node: node) if node.attributes["data-srcset"]
12
- add_offense("Use the native sizes attribute instead of data-sizes", node: node) if node.attributes["data-sizes"]
13
- add_offense("Do not set the data-sizes attribute to auto", node: node) if node.attributes["data-sizes"] == "auto"
14
17
  end
15
18
  end
16
19
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class DeprecatedGlobalAppBlockType < LiquidCheck
4
+ severity :error
5
+ category :liquid
6
+ doc docs_url(__FILE__)
7
+
8
+ INVALID_GLOBAL_APP_BLOCK_TYPE = "@global"
9
+ VALID_GLOBAL_APP_BLOCK_TYPE = "@app"
10
+
11
+ def on_schema(node)
12
+ schema = JSON.parse(node.value.nodelist.join)
13
+
14
+ if block_types_from(schema).include?(INVALID_GLOBAL_APP_BLOCK_TYPE)
15
+ add_offense(
16
+ "Deprecated '#{INVALID_GLOBAL_APP_BLOCK_TYPE}' block type defined in the schema, use '#{VALID_GLOBAL_APP_BLOCK_TYPE}' block type instead.",
17
+ node: node
18
+ )
19
+ end
20
+ rescue JSON::ParserError
21
+ # Ignored, handled in ValidSchema.
22
+ end
23
+
24
+ def on_case(node)
25
+ if node.value == INVALID_GLOBAL_APP_BLOCK_TYPE
26
+ report_offense(node)
27
+ end
28
+ end
29
+
30
+ def on_condition(node)
31
+ if node.value.right == INVALID_GLOBAL_APP_BLOCK_TYPE || node.value.left == INVALID_GLOBAL_APP_BLOCK_TYPE
32
+ report_offense(node)
33
+ end
34
+ end
35
+
36
+ def on_variable(node)
37
+ if node.value.name == INVALID_GLOBAL_APP_BLOCK_TYPE
38
+ report_offense(node)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def report_offense(node)
45
+ add_offense(
46
+ "Deprecated '#{INVALID_GLOBAL_APP_BLOCK_TYPE}' block type, use '#{VALID_GLOBAL_APP_BLOCK_TYPE}' block type instead.",
47
+ node: node
48
+ )
49
+ end
50
+
51
+ def block_types_from(schema)
52
+ schema.fetch("blocks", []).map do |block|
53
+ block.fetch("type", "")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -13,7 +13,7 @@ module ThemeCheck
13
13
  end
14
14
 
15
15
  def on_tag(node)
16
- if !node.inside_liquid_tag?
16
+ if node.inside_liquid_tag?
17
17
  reset_consecutive_statements
18
18
  # Ignore comments
19
19
  elsif !node.comment?
@@ -36,16 +36,32 @@ module ThemeCheck
36
36
  # Ignored, handled in ValidSchema.
37
37
  end
38
38
 
39
+ ##
40
+ # Section settings look like:
41
+ # #<Liquid::VariableLookup:0x00007fd699c50c48 @name="section", @lookups=["settings", "products_per_page"], @command_flags=0>
42
+ def size_is_a_section_setting?(size)
43
+ size.is_a?(Liquid::VariableLookup) &&
44
+ size.name == 'section' &&
45
+ size.lookups.first == 'settings'
46
+ end
47
+
48
+ ##
49
+ # We'll work with either an explicit value, or the default value of the section setting.
50
+ def get_value(size)
51
+ return size if size.is_a?(Numeric)
52
+ return get_setting_default_value(size) if size_is_a_section_setting?(size)
53
+ end
54
+
39
55
  def after_document(_node)
40
56
  @paginations.each_pair do |size, nodes|
41
- numerical_size = if size.is_a?(Numeric)
42
- size
43
- else
44
- get_setting_default_value(size.lookups.last)
45
- end
46
- if numerical_size.nil?
57
+ # Validate presence of default section setting.
58
+ if size_is_a_section_setting?(size) && !get_setting_default_value(size)
47
59
  nodes.each { |node| add_offense("Default pagination size should be defined in the section settings", node: node) }
48
- elsif numerical_size > @max_size || numerical_size < @min_size || !numerical_size.is_a?(Integer)
60
+ end
61
+
62
+ # Validate if size is within range.
63
+ next unless (numerical_size = get_value(size))
64
+ if numerical_size > @max_size || numerical_size < @min_size || !numerical_size.is_a?(Integer)
49
65
  nodes.each { |node| add_offense("Pagination size must be a positive integer between #{@min_size} and #{@max_size}", node: node) }
50
66
  end
51
67
  end
@@ -53,13 +69,16 @@ module ThemeCheck
53
69
 
54
70
  private
55
71
 
56
- def get_setting_default_value(setting_id)
57
- setting = @schema_settings.select { |s| s['id'] == setting_id }
58
- unless setting.empty?
59
- return setting.last['default']
60
- end
61
- # Setting does not exist
62
- nil
72
+ def get_setting_default_value(variable_lookup)
73
+ setting = @schema_settings.select { |s| s['id'] == variable_lookup.lookups.last }
74
+
75
+ # Setting does not exist.
76
+ return nil if setting.empty?
77
+
78
+ default_value = setting.last['default'].to_i
79
+ return nil if default_value == 0
80
+
81
+ default_value
63
82
  end
64
83
  end
65
84
  end
@@ -23,9 +23,9 @@ module ThemeCheck
23
23
  return if resource_url =~ RELATIVE_PATH
24
24
  return if url_hosted_by_shopify?(resource_url)
25
25
 
26
- # Ignore non-stylesheet rel tags
26
+ # Ignore non-stylesheet link tags
27
27
  rel = node.attributes["rel"]
28
- return if rel && rel != "stylesheet"
28
+ return if node.name == "link" && rel != "stylesheet"
29
29
 
30
30
  add_offense(
31
31
  "Asset should be served by the Shopify CDN for better performance.",
@@ -18,7 +18,9 @@ module ThemeCheck
18
18
  private
19
19
 
20
20
  def add_missing_directories_offense(directory)
21
- add_offense("Theme is missing '#{directory}' directory")
21
+ add_offense("Theme is missing '#{directory}' directory") do |corrector|
22
+ corrector.mkdir(@theme, directory)
23
+ end
22
24
  end
23
25
  end
24
26
  end
@@ -15,52 +15,57 @@ module ThemeCheck
15
15
  return if :assign == node.type_name
16
16
 
17
17
  outside_of_strings(node.markup) do |chunk, chunk_start|
18
- chunk.scan(/([,:|]|==|<>|<=|>=|<|>|!=) +/) do |_match|
18
+ chunk.scan(/([,:|]|==|<>|<=|>=|<|>|!=)( +)/) do |_match|
19
19
  add_offense(
20
20
  "Too many spaces after '#{Regexp.last_match(1)}'",
21
21
  node: node,
22
- markup: Regexp.last_match(0),
23
- node_markup_offset: chunk_start + Regexp.last_match.begin(0)
22
+ markup: Regexp.last_match(2),
23
+ node_markup_offset: chunk_start + Regexp.last_match.begin(2)
24
24
  )
25
25
  end
26
26
  chunk.scan(/([,:|]|==|<>|<=|>=|<\b|>\b|!=)(\S|\z)/) do |_match|
27
27
  add_offense(
28
28
  "Space missing after '#{Regexp.last_match(1)}'",
29
29
  node: node,
30
- markup: Regexp.last_match(0),
30
+ markup: Regexp.last_match(1),
31
31
  node_markup_offset: chunk_start + Regexp.last_match.begin(0),
32
32
  )
33
33
  end
34
- chunk.scan(/ (\||==|<>|<=|>=|<|>|!=)+/) do |_match|
34
+ chunk.scan(/( +)(\||==|<>|<=|>=|<|>|!=)+/) do |_match|
35
35
  add_offense(
36
- "Too many spaces before '#{Regexp.last_match(1)}'",
36
+ "Too many spaces before '#{Regexp.last_match(2)}'",
37
37
  node: node,
38
- markup: Regexp.last_match(0),
39
- node_markup_offset: chunk_start + Regexp.last_match.begin(0)
38
+ markup: Regexp.last_match(1),
39
+ node_markup_offset: chunk_start + Regexp.last_match.begin(1)
40
40
  )
41
41
  end
42
42
  chunk.scan(/(\A|\S)(?<match>\||==|<>|<=|>=|<|\b>|!=)/) do |_match|
43
43
  add_offense(
44
44
  "Space missing before '#{Regexp.last_match(1)}'",
45
45
  node: node,
46
- markup: Regexp.last_match(0),
47
- node_markup_offset: chunk_start + Regexp.last_match.begin(0)
46
+ markup: Regexp.last_match(:match),
47
+ node_markup_offset: chunk_start + Regexp.last_match.begin(:match)
48
48
  )
49
49
  end
50
50
  end
51
51
  end
52
52
 
53
53
  def on_tag(node)
54
- if node.inside_liquid_tag?
55
- markup = if node.whitespace_trimmed?
56
- "-%}"
57
- else
58
- "%}"
59
- end
54
+ unless node.inside_liquid_tag?
60
55
  if node.markup[-1] != " " && node.markup[-1] != "\n"
61
- add_offense("Space missing before '#{markup}'", node: node, markup: node.markup[-1] + markup)
56
+ add_offense(
57
+ "Space missing before '#{node.end_token}'",
58
+ node: node,
59
+ markup: node.markup[-1],
60
+ node_markup_offset: node.markup.size - 1,
61
+ )
62
62
  elsif node.markup =~ /(\n?)( +)\z/m && Regexp.last_match(1) != "\n"
63
- add_offense("Too many spaces before '#{markup}'", node: node, markup: Regexp.last_match(2) + markup)
63
+ add_offense(
64
+ "Too many spaces before '#{node.end_token}'",
65
+ node: node,
66
+ markup: Regexp.last_match(2),
67
+ node_markup_offset: node.markup.size - Regexp.last_match(2).size
68
+ )
64
69
  end
65
70
  end
66
71
  @ignore = true
@@ -73,22 +78,40 @@ module ThemeCheck
73
78
  def on_variable(node)
74
79
  return if @ignore || node.markup.empty?
75
80
  if node.markup[0] != " "
76
- add_offense("Space missing after '{{'", node: node) do |corrector|
81
+ add_offense(
82
+ "Space missing after '#{node.start_token}'",
83
+ node: node,
84
+ markup: node.markup[0]
85
+ ) do |corrector|
77
86
  corrector.insert_before(node, " ")
78
87
  end
79
88
  end
80
89
  if node.markup[-1] != " " && node.markup[-1] != "\n"
81
- add_offense("Space missing before '}}'", node: node) do |corrector|
90
+ add_offense(
91
+ "Space missing before '#{node.end_token}'",
92
+ node: node,
93
+ markup: node.markup[-1],
94
+ node_markup_offset: node.markup.size - 1,
95
+ ) do |corrector|
82
96
  corrector.insert_after(node, " ")
83
97
  end
84
98
  end
85
- if node.markup[0] == " " && node.markup[1] == " "
86
- add_offense("Too many spaces after '{{'", node: node) do |corrector|
99
+ if node.markup =~ /\A( +)/m
100
+ add_offense(
101
+ "Too many spaces after '#{node.start_token}'",
102
+ node: node,
103
+ markup: Regexp.last_match(1),
104
+ ) do |corrector|
87
105
  corrector.replace(node, " #{node.markup.lstrip}")
88
106
  end
89
107
  end
90
- if node.markup[-1] == " " && node.markup[-2] == " "
91
- add_offense("Too many spaces before '}}'", node: node) do |corrector|
108
+ if node.markup =~ /(\n?)( +)\z/m && Regexp.last_match(1) != "\n"
109
+ add_offense(
110
+ "Too many spaces before '#{node.end_token}'",
111
+ node: node,
112
+ markup: Regexp.last_match(2),
113
+ node_markup_offset: node.markup.size - Regexp.last_match(2).size
114
+ ) do |corrector|
92
115
  corrector.replace(node, "#{node.markup.rstrip} ")
93
116
  end
94
117
  end
@@ -5,7 +5,7 @@ module ThemeCheck
5
5
  category :liquid
6
6
  doc docs_url(__FILE__)
7
7
 
8
- def initialize(max_length: 500, exclude_schema: true, exclude_stylesheet: true, exclude_javascript: true)
8
+ def initialize(max_length: 600, exclude_schema: true, exclude_stylesheet: true, exclude_javascript: true)
9
9
  @max_length = max_length
10
10
  @exclude_schema = exclude_schema
11
11
  @exclude_stylesheet = exclude_stylesheet
@@ -78,6 +78,15 @@ module ThemeCheck
78
78
  "Print Theme Check version"
79
79
  ) { @command = :version }
80
80
 
81
+ if ENV["THEME_CHECK_DEBUG"]
82
+ @option_parser.separator("")
83
+ @option_parser.separator("Debugging:")
84
+ @option_parser.on(
85
+ "--profile",
86
+ "Output a profile to STDOUT compatible with FlameGraph."
87
+ ) { @command = :profile }
88
+ end
89
+
81
90
  @option_parser.separator("")
82
91
  @option_parser.separator(<<~EOS)
83
92
  Description:
@@ -172,7 +181,7 @@ module ThemeCheck
172
181
  puts option_parser.to_s
173
182
  end
174
183
 
175
- def check
184
+ def check(out_stream = STDOUT)
176
185
  STDERR.puts "Checking #{@config.root} ..."
177
186
  storage = ThemeCheck::FileSystemStorage.new(@config.root, ignored_patterns: @config.ignored_patterns)
178
187
  theme = ThemeCheck::Theme.new(storage)
@@ -182,18 +191,32 @@ module ThemeCheck
182
191
  analyzer = ThemeCheck::Analyzer.new(theme, @config.enabled_checks, @config.auto_correct)
183
192
  analyzer.analyze_theme
184
193
  analyzer.correct_offenses
185
- output_with_format(theme, analyzer)
194
+ output_with_format(theme, analyzer, out_stream)
186
195
  raise Abort, "" if analyzer.uncorrectable_offenses.any? do |offense|
187
196
  offense.check.severity_value <= Check.severity_value(@fail_level)
188
197
  end
189
198
  end
190
199
 
191
- def output_with_format(theme, analyzer)
200
+ def profile
201
+ require 'ruby-prof-flamegraph'
202
+
203
+ result = RubyProf.profile do
204
+ check(STDERR)
205
+ end
206
+
207
+ # Print a graph profile to text
208
+ printer = RubyProf::FlameGraphPrinter.new(result)
209
+ printer.print(STDOUT, {})
210
+ rescue LoadError
211
+ STDERR.puts "Profiling is only available in development"
212
+ end
213
+
214
+ def output_with_format(theme, analyzer, out_stream)
192
215
  case @format
193
216
  when :text
194
- ThemeCheck::Printer.new.print(theme, analyzer.offenses, @config.auto_correct)
217
+ ThemeCheck::Printer.new(out_stream).print(theme, analyzer.offenses, @config.auto_correct)
195
218
  when :json
196
- ThemeCheck::JsonPrinter.new.print(analyzer.offenses)
219
+ ThemeCheck::JsonPrinter.new(out_stream).print(analyzer.offenses)
197
220
  end
198
221
  end
199
222
  end
@@ -31,5 +31,14 @@ module ThemeCheck
31
31
  def create(theme, relative_path, content)
32
32
  theme.storage.write(relative_path, content)
33
33
  end
34
+
35
+ def create_default_locale_json(theme)
36
+ theme.default_locale_json = JsonFile.new("locales/#{theme.default_locale}.default.json", theme.storage)
37
+ theme.default_locale_json.update_contents('{}')
38
+ end
39
+
40
+ def mkdir(theme, relative_path)
41
+ theme.storage.mkdir(relative_path)
42
+ end
34
43
  end
35
44
  end
@@ -26,6 +26,12 @@ module ThemeCheck
26
26
  file(relative_path).write(content)
27
27
  end
28
28
 
29
+ def mkdir(relative_path)
30
+ reset_memoizers unless file_exists?(relative_path)
31
+
32
+ file(relative_path).mkpath unless file(relative_path).directory?
33
+ end
34
+
29
35
  def files
30
36
  @file_array ||= glob("**/*")
31
37
  .map { |path| path.relative_path_from(@root).to_s }
@@ -23,6 +23,10 @@ module ThemeCheck
23
23
  @files[relative_path] = content
24
24
  end
25
25
 
26
+ def mkdir(relative_path)
27
+ @files[relative_path] = nil
28
+ end
29
+
26
30
  def files
27
31
  @files.keys
28
32
  end
@@ -20,6 +20,17 @@ module ThemeCheck
20
20
  @parser_error
21
21
  end
22
22
 
23
+ def update_contents(new_content = '{}')
24
+ @content = new_content
25
+ end
26
+
27
+ def write
28
+ if source != @content
29
+ @storage.write(@relative_path, content)
30
+ @source = content
31
+ end
32
+ end
33
+
23
34
  def json?
24
35
  true
25
36
  end