theme-check 1.5.0 → 1.6.1

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +12 -4
  3. data/CHANGELOG.md +34 -0
  4. data/CONTRIBUTING.md +58 -0
  5. data/Gemfile +3 -0
  6. data/docs/flamegraph.svg +18488 -0
  7. data/lib/theme_check/analyzer.rb +5 -0
  8. data/lib/theme_check/asset_file.rb +13 -2
  9. data/lib/theme_check/check.rb +1 -1
  10. data/lib/theme_check/checks/asset_size_css.rb +15 -0
  11. data/lib/theme_check/checks/asset_size_css_stylesheet_tag.rb +18 -1
  12. data/lib/theme_check/checks/convert_include_to_render.rb +2 -1
  13. data/lib/theme_check/checks/liquid_tag.rb +1 -1
  14. data/lib/theme_check/checks/missing_required_template_files.rb +21 -7
  15. data/lib/theme_check/checks/pagination_size.rb +30 -10
  16. data/lib/theme_check/checks/required_directories.rb +3 -1
  17. data/lib/theme_check/checks/space_inside_braces.rb +47 -24
  18. data/lib/theme_check/checks/translation_key_exists.rb +3 -1
  19. data/lib/theme_check/checks/unused_snippet.rb +3 -1
  20. data/lib/theme_check/checks.rb +2 -0
  21. data/lib/theme_check/cli.rb +32 -6
  22. data/lib/theme_check/corrector.rb +23 -10
  23. data/lib/theme_check/file_system_storage.rb +13 -2
  24. data/lib/theme_check/html_node.rb +4 -4
  25. data/lib/theme_check/html_visitor.rb +20 -8
  26. data/lib/theme_check/in_memory_storage.rb +8 -0
  27. data/lib/theme_check/json_file.rb +9 -4
  28. data/lib/theme_check/json_printer.rb +5 -1
  29. data/lib/theme_check/node.rb +118 -11
  30. data/lib/theme_check/offense.rb +26 -0
  31. data/lib/theme_check/position.rb +27 -16
  32. data/lib/theme_check/position_helper.rb +13 -15
  33. data/lib/theme_check/printer.rb +9 -5
  34. data/lib/theme_check/regex_helpers.rb +1 -15
  35. data/lib/theme_check/remote_asset_file.rb +4 -0
  36. data/lib/theme_check/template.rb +5 -19
  37. data/lib/theme_check/template_rewriter.rb +57 -0
  38. data/lib/theme_check/theme_file.rb +18 -1
  39. data/lib/theme_check/version.rb +1 -1
  40. data/lib/theme_check.rb +1 -0
  41. data/theme-check.gemspec +1 -0
  42. metadata +18 -2
@@ -86,6 +86,11 @@ module ThemeCheck
86
86
  def correct_offenses
87
87
  if @auto_correct
88
88
  offenses.each(&:correct)
89
+ end
90
+ end
91
+
92
+ def write_corrections
93
+ if @auto_correct
89
94
  @theme.liquid.each(&:write)
90
95
  @theme.json.each(&:write)
91
96
  end
@@ -9,10 +9,21 @@ module ThemeCheck
9
9
  @content = nil
10
10
  end
11
11
 
12
- alias_method :content, :source
12
+ def rewriter
13
+ @rewriter ||= TemplateRewriter.new(@relative_path, source)
14
+ end
15
+
16
+ def write
17
+ content = rewriter.to_s
18
+ if source != content
19
+ @storage.write(@relative_path, content.gsub("\n", @eol))
20
+ @source = content
21
+ @rewriter = nil
22
+ end
23
+ end
13
24
 
14
25
  def gzipped_size
15
- @gzipped_size ||= Zlib.gzip(content).bytesize
26
+ @gzipped_size ||= Zlib.gzip(source).bytesize
16
27
  end
17
28
 
18
29
  def name
@@ -46,7 +46,7 @@ module ThemeCheck
46
46
  end
47
47
 
48
48
  def severity_value(severity)
49
- SEVERITY_VALUES[severity]
49
+ SEVERITY_VALUES[severity] || -1
50
50
  end
51
51
 
52
52
  def categories(*categories)
@@ -22,5 +22,20 @@ module ThemeCheck
22
22
  node: node
23
23
  )
24
24
  end
25
+
26
+ def href_to_file_size(href)
27
+ # asset_url (+ optional stylesheet_tag) variables
28
+ if href =~ /^#{LIQUID_VARIABLE}$/o && href =~ /asset_url/ && href =~ Liquid::QuotedString
29
+ asset_id = Regexp.last_match(0).gsub(START_OR_END_QUOTE, "")
30
+ asset = @theme.assets.find { |a| a.name.end_with?("/" + asset_id) }
31
+ return if asset.nil?
32
+ asset.gzipped_size
33
+
34
+ # remote URLs
35
+ elsif href =~ %r{^(https?:)?//}
36
+ asset = RemoteAssetFile.from_src(href)
37
+ asset.gzipped_size
38
+ end
39
+ end
25
40
  end
26
41
  end
@@ -13,12 +13,29 @@ module ThemeCheck
13
13
  def on_variable(node)
14
14
  used_filters = node.value.filters.map { |name, *_rest| name }
15
15
  return unless used_filters.include?("stylesheet_tag")
16
- file_size = href_to_file_size('{{' + node.markup + '}}')
16
+ file_size = stylesheet_tag_pipeline_to_file_size(node.markup)
17
+ return if file_size.nil?
17
18
  return if file_size <= @threshold_in_bytes
18
19
  add_offense(
19
20
  "CSS on every page load exceeding compressed size threshold (#{@threshold_in_bytes} Bytes).",
20
21
  node: node
21
22
  )
22
23
  end
24
+
25
+ def stylesheet_tag_pipeline_to_file_size(href)
26
+ # asset_url
27
+ if href =~ /asset_url/ && href =~ Liquid::QuotedString
28
+ asset_id = Regexp.last_match(0).gsub(START_OR_END_QUOTE, "")
29
+ asset = @theme.assets.find { |a| a.name.end_with?("/" + asset_id) }
30
+ return if asset.nil?
31
+ asset.gzipped_size
32
+
33
+ # remote URLs
34
+ elsif href =~ %r{(https?:)?//[^'"]+}
35
+ url = Regexp.last_match(0)
36
+ asset = RemoteAssetFile.from_src(url)
37
+ asset.gzipped_size
38
+ end
39
+ end
23
40
  end
24
41
  end
@@ -8,7 +8,8 @@ module ThemeCheck
8
8
 
9
9
  def on_include(node)
10
10
  add_offense("`include` is deprecated - convert it to `render`", node: node) do |corrector|
11
- corrector.replace(node, "render \'#{node.value.template_name_expr}\' ")
11
+ # We need to fix #445 and pass the variables from the context or don't replace at all.
12
+ # corrector.replace(node, "render \'#{node.value.template_name_expr}\' ")
12
13
  end
13
14
  end
14
15
  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?
@@ -9,19 +9,33 @@ module ThemeCheck
9
9
  doc docs_url(__FILE__)
10
10
 
11
11
  REQUIRED_LIQUID_FILES = %w(layout/theme)
12
- REQUIRED_TEMPLATE_FILES = %w(
13
- index product collection cart blog article page list-collections search 404
12
+
13
+ REQUIRED_LIQUID_TEMPLATE_FILES = %w(
14
14
  gift_card customers/account customers/activate_account customers/addresses
15
- customers/login customers/order customers/register customers/reset_password password
16
- )
17
- .map { |file| "templates/#{file}" }
15
+ customers/login customers/order customers/register customers/reset_password
16
+ ).map { |file| "templates/#{file}" }
17
+
18
+ REQUIRED_JSON_TEMPLATE_FILES = %w(
19
+ index product collection cart blog article page list-collections search 404
20
+ password
21
+ ).map { |file| "templates/#{file}" }
22
+
23
+ REQUIRED_TEMPLATE_FILES = (REQUIRED_LIQUID_TEMPLATE_FILES + REQUIRED_JSON_TEMPLATE_FILES)
18
24
 
19
25
  def on_end
20
26
  (REQUIRED_LIQUID_FILES - theme.liquid.map(&:name)).each do |file|
21
- add_offense("'#{file}.liquid' is missing")
27
+ add_offense("'#{file}.liquid' is missing") do |corrector|
28
+ corrector.create(@theme, "#{file}.liquid", "")
29
+ end
22
30
  end
23
31
  (REQUIRED_TEMPLATE_FILES - (theme.liquid + theme.json).map(&:name)).each do |file|
24
- add_offense("'#{file}.liquid' or '#{file}.json' is missing")
32
+ add_offense("'#{file}.liquid' or '#{file}.json' is missing") do |corrector|
33
+ if REQUIRED_LIQUID_TEMPLATE_FILES.include?(file)
34
+ corrector.create(@theme, "#{file}.liquid", "")
35
+ else
36
+ corrector.create(@theme, "#{file}.json", "")
37
+ end
38
+ end
25
39
  end
26
40
  end
27
41
  end
@@ -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,11 +69,15 @@ module ThemeCheck
53
69
 
54
70
  private
55
71
 
56
- def get_setting_default_value(setting_id)
57
- setting = @schema_settings.find { |s| s['id'] == setting_id }
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.
58
76
  return nil if setting.empty?
59
- default_value = setting['default'].to_i
77
+
78
+ default_value = setting.last['default'].to_i
60
79
  return nil if default_value == 0
80
+
61
81
  default_value
62
82
  end
63
83
  end
@@ -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
@@ -29,7 +29,9 @@ module ThemeCheck
29
29
  "'#{key_node.value}' does not have a matching entry in '#{@theme.default_locale_json.relative_path}'",
30
30
  node: node,
31
31
  markup: key_node.value,
32
- )
32
+ ) do |corrector|
33
+ corrector.add_default_translation_key(@theme.default_locale_json, key_node.value.split("."), "TODO")
34
+ end
33
35
  end
34
36
  end
35
37
 
@@ -24,7 +24,9 @@ module ThemeCheck
24
24
 
25
25
  def on_end
26
26
  missing_snippets.each do |template|
27
- add_offense("This template is not used", template: template)
27
+ add_offense("This template is not used", template: template) do |corrector|
28
+ corrector.remove(@theme, template.relative_path.to_s)
29
+ end
28
30
  end
29
31
  end
30
32
 
@@ -50,6 +50,7 @@ module ThemeCheck
50
50
  template = node.respond_to?(:template) ? node.template.relative_path : "?"
51
51
  markup = node.respond_to?(:markup) ? node.markup : ""
52
52
  node_class = node.respond_to?(:value) ? node.value.class : "?"
53
+ line_number = node.respond_to?(:line_number) ? node.line_number : "?"
53
54
 
54
55
  ThemeCheck.bug(<<~EOS)
55
56
  Exception while running `#{check.code_name}##{method}`:
@@ -64,6 +65,7 @@ module ThemeCheck
64
65
  ```
65
66
  #{markup}
66
67
  ```
68
+ Line number: #{line_number}
67
69
  Check options: `#{check.options.pretty_inspect}`
68
70
  EOS
69
71
  end
@@ -49,7 +49,7 @@ module ThemeCheck
49
49
  "Automatically fix offenses"
50
50
  ) { @auto_correct = true }
51
51
  @option_parser.on(
52
- "--fail-level SEVERITY", Check::SEVERITIES,
52
+ "--fail-level SEVERITY", [:crash] + Check::SEVERITIES,
53
53
  "Minimum severity (error|suggestion|style) for exit with error code"
54
54
  ) do |severity|
55
55
  @fail_level = severity.to_sym
@@ -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,35 @@ 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
+ print_with_format(theme, analyzer, out_stream)
195
+ # corrections are committed after printing so that the
196
+ # source_excerpts are still pointing to the uncorrected source.
197
+ analyzer.write_corrections
186
198
  raise Abort, "" if analyzer.uncorrectable_offenses.any? do |offense|
187
199
  offense.check.severity_value <= Check.severity_value(@fail_level)
188
200
  end
189
201
  end
190
202
 
191
- def output_with_format(theme, analyzer)
203
+ def profile
204
+ require 'ruby-prof-flamegraph'
205
+
206
+ result = RubyProf.profile do
207
+ check(STDERR)
208
+ end
209
+
210
+ # Print a graph profile to text
211
+ printer = RubyProf::FlameGraphPrinter.new(result)
212
+ printer.print(STDOUT, {})
213
+ rescue LoadError
214
+ STDERR.puts "Profiling is only available in development"
215
+ end
216
+
217
+ def print_with_format(theme, analyzer, out_stream)
192
218
  case @format
193
219
  when :text
194
- ThemeCheck::Printer.new.print(theme, analyzer.offenses, @config.auto_correct)
220
+ ThemeCheck::Printer.new(out_stream).print(theme, analyzer.offenses, @config.auto_correct)
195
221
  when :json
196
- ThemeCheck::JsonPrinter.new.print(analyzer.offenses)
222
+ ThemeCheck::JsonPrinter.new(out_stream).print(analyzer.offenses)
197
223
  end
198
224
  end
199
225
  end