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
@@ -7,25 +7,20 @@ module ThemeCheck
7
7
  end
8
8
 
9
9
  def insert_after(node, content)
10
- line = @template.full_line(node.line_number)
11
- line.insert(node.range[1] + 1, content)
10
+ @template.rewriter.insert_after(node, content)
12
11
  end
13
12
 
14
13
  def insert_before(node, content)
15
- line = @template.full_line(node.line_number)
16
- line.insert(node.range[0], content)
14
+ @template.rewriter.insert_before(node, content)
17
15
  end
18
16
 
19
17
  def replace(node, content)
20
- line = @template.full_line(node.line_number)
21
- line[node.range[0]..node.range[1]] = content
18
+ @template.rewriter.replace(node, content)
22
19
  node.markup = content
23
20
  end
24
21
 
25
22
  def wrap(node, insert_before, insert_after)
26
- line = @template.full_line(node.line_number)
27
- line.insert(node.range[0], insert_before)
28
- line.insert(node.range[1] + 1 + insert_before.length, insert_after)
23
+ @template.rewriter.wrap(node, insert_before, insert_after)
29
24
  end
30
25
 
31
26
  def create(theme, relative_path, content)
@@ -34,7 +29,25 @@ module ThemeCheck
34
29
 
35
30
  def create_default_locale_json(theme)
36
31
  theme.default_locale_json = JsonFile.new("locales/#{theme.default_locale}.default.json", theme.storage)
37
- theme.default_locale_json.update_contents('{}')
32
+ theme.default_locale_json.update_contents({})
33
+ end
34
+
35
+ def remove(theme, relative_path)
36
+ theme.storage.remove(relative_path)
37
+ end
38
+
39
+ def mkdir(theme, relative_path)
40
+ theme.storage.mkdir(relative_path)
41
+ end
42
+
43
+ def add_default_translation_key(file, key, value)
44
+ hash = file.content
45
+ key.reduce(hash) do |pointer, token|
46
+ return pointer[token] = value if token == key.last
47
+ pointer[token] = {} unless pointer.key?(token)
48
+ pointer[token]
49
+ end
50
+ file.update_contents(hash)
38
51
  end
39
52
  end
40
53
  end
@@ -16,14 +16,25 @@ module ThemeCheck
16
16
  end
17
17
 
18
18
  def read(relative_path)
19
- file(relative_path).read
19
+ file(relative_path).read(mode: 'rb', encoding: 'UTF-8')
20
20
  end
21
21
 
22
22
  def write(relative_path, content)
23
23
  reset_memoizers unless file_exists?(relative_path)
24
24
 
25
25
  file(relative_path).dirname.mkpath unless file(relative_path).dirname.directory?
26
- file(relative_path).write(content)
26
+ file(relative_path).write(content, mode: 'w+b', encoding: 'UTF-8')
27
+ end
28
+
29
+ def remove(relative_path)
30
+ file(relative_path).delete
31
+ reset_memoizers
32
+ end
33
+
34
+ def mkdir(relative_path)
35
+ reset_memoizers unless file_exists?(relative_path)
36
+
37
+ file(relative_path).mkpath unless file(relative_path).directory?
27
38
  end
28
39
 
29
40
  def files
@@ -67,10 +67,10 @@ module ThemeCheck
67
67
  private
68
68
 
69
69
  def replace_placeholders(string)
70
- # Replace all {%#{i}####%} with the actual content.
71
- string.gsub(LIQUID_TAG) do |match|
72
- key = /\d+/.match(match)[0]
73
- @placeholder_values[key.to_i]
70
+ # Replace all {i}####≬ with the actual content.
71
+ string.gsub(HTML_LIQUID_PLACEHOLDER) do |match|
72
+ key = /[0-9a-z]+/.match(match)[0]
73
+ @placeholder_values[key.to_i(36)]
74
74
  end
75
75
  end
76
76
  end
@@ -9,12 +9,11 @@ module ThemeCheck
9
9
 
10
10
  def initialize(checks)
11
11
  @checks = checks
12
- @placeholder_values = []
13
12
  end
14
13
 
15
14
  def visit_template(template)
16
- doc = parse(template)
17
- visit(HtmlNode.new(doc, template, @placeholder_values))
15
+ doc, placeholder_values = parse(template)
16
+ visit(HtmlNode.new(doc, template, placeholder_values))
18
17
  rescue ArgumentError => e
19
18
  call_checks(:on_parse_error, e, template)
20
19
  end
@@ -22,19 +21,32 @@ module ThemeCheck
22
21
  private
23
22
 
24
23
  def parse(template)
24
+ placeholder_values = []
25
25
  parseable_source = +template.source.clone
26
26
 
27
- # Replace all liquid tags with {%#{i}######%} to prevent the HTML
27
+ # Replace all non-empty liquid tags with {i}######≬ to prevent the HTML
28
28
  # parser from freaking out. We transparently replace those placeholders in
29
29
  # HtmlNode.
30
+ #
31
+ # We're using base36 to prevent index bleeding on 36^3 tags.
32
+ # `{{x}}` -> `≬#{i}≬` would properly be transformed for 46656 tags in a single file.
33
+ # Should be enough.
34
+ #
35
+ # The base10 alternative would have overflowed at 1000 (`{{x}}` -> `≬1000≬`) which seemed more likely.
36
+ #
37
+ # Didn't go with base64 because of the `=` character that would have messed with HTML parsing.
30
38
  matches(parseable_source, LIQUID_TAG_OR_VARIABLE).each do |m|
31
39
  value = m[0]
32
- @placeholder_values.push(value)
33
- key = (@placeholder_values.size - 1).to_s
34
- parseable_source[m.begin(0)...m.end(0)] = "{%#{key.ljust(m.end(0) - m.begin(0) - 4, '#')}%}"
40
+ next unless value.size > 4 # skip empty tags/variables {%%} and {{}}
41
+ placeholder_values.push(value)
42
+ key = (placeholder_values.size - 1).to_s(36)
43
+ parseable_source[m.begin(0)...m.end(0)] = "≬#{key.ljust(m.end(0) - m.begin(0) - 2, '#')}≬"
35
44
  end
36
45
 
37
- Nokogiri::HTML5.fragment(parseable_source, max_tree_depth: 400, max_attributes: 400)
46
+ [
47
+ Nokogiri::HTML5.fragment(parseable_source, max_tree_depth: 400, max_attributes: 400),
48
+ placeholder_values,
49
+ ]
38
50
  end
39
51
 
40
52
  def visit(node)
@@ -23,6 +23,14 @@ module ThemeCheck
23
23
  @files[relative_path] = content
24
24
  end
25
25
 
26
+ def remove(relative_path)
27
+ @files.delete(relative_path)
28
+ end
29
+
30
+ def mkdir(relative_path)
31
+ @files[relative_path] = nil
32
+ end
33
+
26
34
  def files
27
35
  @files.keys
28
36
  end
@@ -20,14 +20,19 @@ module ThemeCheck
20
20
  @parser_error
21
21
  end
22
22
 
23
- def update_contents(new_content = '{}')
23
+ def update_contents(new_content = {})
24
+ raise ArgumentError if new_content.is_a?(String)
24
25
  @content = new_content
25
26
  end
26
27
 
27
28
  def write
28
- if source != @content
29
- @storage.write(@relative_path, content)
30
- @source = content
29
+ pretty = JSON.pretty_generate(@content)
30
+ if source.rstrip != pretty.rstrip
31
+ # Most editors add a trailing \n at the end of files. Here we
32
+ # try to maintain the convention.
33
+ eof = source.end_with?("\n") ? "\n" : ""
34
+ @storage.write(@relative_path, pretty.gsub("\n", @eol) + eof)
35
+ @source = pretty
31
36
  end
32
37
  end
33
38
 
@@ -3,9 +3,13 @@ require 'json'
3
3
 
4
4
  module ThemeCheck
5
5
  class JsonPrinter
6
+ def initialize(out_stream = STDOUT)
7
+ @out = out_stream
8
+ end
9
+
6
10
  def print(offenses)
7
11
  json = offenses_by_path(offenses)
8
- puts JSON.dump(json)
12
+ @out.puts JSON.dump(json)
9
13
  end
10
14
 
11
15
  def offenses_by_path(offenses)
@@ -10,12 +10,14 @@ module ThemeCheck
10
10
  @value = value
11
11
  @parent = parent
12
12
  @template = template
13
+ @tag_markup = nil
14
+ @line_number_offset = 0
13
15
  end
14
16
 
15
17
  # The original source code of the node. Doesn't contain wrapping braces.
16
18
  def markup
17
19
  if tag?
18
- @value.raw
20
+ tag_markup
19
21
  elsif @value.instance_variable_defined?(:@markup)
20
22
  @value.instance_variable_get(:@markup)
21
23
  end
@@ -64,6 +66,10 @@ module ThemeCheck
64
66
  @value.is_a?(Liquid::Tag)
65
67
  end
66
68
 
69
+ def variable?
70
+ @value.is_a?(Liquid::Variable)
71
+ end
72
+
67
73
  # A {% comment %} block node?
68
74
  def comment?
69
75
  @value.is_a?(Liquid::Comment)
@@ -92,7 +98,12 @@ module ThemeCheck
92
98
 
93
99
  # Most nodes have a line number, but it's not guaranteed.
94
100
  def line_number
95
- @value.line_number if @value.respond_to?(:line_number)
101
+ if tag? && @value.respond_to?(:line_number)
102
+ markup # initialize the line_number_offset
103
+ @value.line_number - @line_number_offset
104
+ elsif @value.respond_to?(:line_number)
105
+ @value.line_number
106
+ end
96
107
  end
97
108
 
98
109
  # The `:under_score_name` of this type of node. Used to dispatch to the `on_<type_name>`
@@ -101,27 +112,48 @@ module ThemeCheck
101
112
  @type_name ||= StringHelpers.underscore(StringHelpers.demodulize(@value.class.name)).to_sym
102
113
  end
103
114
 
115
+ def source
116
+ template&.source
117
+ end
118
+
119
+ WHITESPACE = /\s/
120
+
104
121
  # Is this node inside a `{% liquid ... %}` block?
105
122
  def inside_liquid_tag?
106
- if line_number
107
- template.excerpt(line_number).start_with?("{%")
123
+ # What we're doing here is starting at the start of the tag and
124
+ # backtrack on all the whitespace until we land on something. If
125
+ # that something is {% or %-, then we can safely assume that
126
+ # we're inside a full tag and not a liquid tag.
127
+ @inside_liquid_tag ||= if tag? && line_number && source
128
+ i = 1
129
+ i += 1 while source[start_index - i] =~ WHITESPACE && i < start_index
130
+ first_two_backtracked_characters = source[(start_index - i - 1)..(start_index - i)]
131
+ first_two_backtracked_characters != "{%" && first_two_backtracked_characters != "%-"
108
132
  else
109
133
  false
110
134
  end
111
135
  end
112
136
 
113
- # Is this node inside a `{%- ... -%}`
114
- def whitespace_trimmed?
115
- if line_number
116
- template.excerpt(line_number).start_with?("{%-")
137
+ # Is this node inside a tag or variable that starts by removing whitespace. i.e. {%- or {{-
138
+ def whitespace_trimmed_start?
139
+ @whitespace_trimmed_start ||= if line_number && source && !inside_liquid_tag?
140
+ i = 1
141
+ i += 1 while source[start_index - i] =~ WHITESPACE && i < start_index
142
+ source[start_index - i] == "-"
117
143
  else
118
144
  false
119
145
  end
120
146
  end
121
147
 
122
- def range
123
- start = template.full_line(line_number).index(markup)
124
- [start, start + markup.length - 1]
148
+ # Is this node inside a tag or variable ends starts by removing whitespace. i.e. -%} or -}}
149
+ def whitespace_trimmed_end?
150
+ @whitespace_trimmed_end ||= if line_number && source && !inside_liquid_tag?
151
+ i = 0
152
+ i += 1 while source[end_index + i] =~ WHITESPACE && i < source.size
153
+ source[end_index + i] == "-"
154
+ else
155
+ false
156
+ end
125
157
  end
126
158
 
127
159
  def position
@@ -139,5 +171,80 @@ module ThemeCheck
139
171
  def end_index
140
172
  position.end_index
141
173
  end
174
+
175
+ def start_token
176
+ return "" if inside_liquid_tag?
177
+ output = ""
178
+ output += "{{" if variable?
179
+ output += "{%" if tag?
180
+ output += "-" if whitespace_trimmed_start?
181
+ output
182
+ end
183
+
184
+ def end_token
185
+ return "" if inside_liquid_tag?
186
+ output = ""
187
+ output += "-" if whitespace_trimmed_end?
188
+ output += "}}" if variable?
189
+ output += "%}" if tag?
190
+ output
191
+ end
192
+
193
+ private
194
+
195
+ # Here we're hacking around a glorious bug in Liquid that makes it so the
196
+ # line_number and markup of a tag is wrong if there's whitespace
197
+ # between the tag_name and the markup of the tag.
198
+ #
199
+ # {%
200
+ # render
201
+ # 'foo'
202
+ # %}
203
+ #
204
+ # Returns a raw value of "render 'foo'\n".
205
+ # The "\n " between render and 'foo' got replaced by a single space.
206
+ #
207
+ # And the line number is the one of 'foo'\n%}. Yay!
208
+ #
209
+ # This breaks any kind of position logic we have since that string
210
+ # does not exist in the template.
211
+ def tag_markup
212
+ return @value.tag_name if @value.instance_variable_get('@markup').empty?
213
+ return @tag_markup if @tag_markup
214
+
215
+ l = 1
216
+ scanner = StringScanner.new(source)
217
+ scanner.scan_until(/\n/) while l < @value.line_number && (l += 1)
218
+ start = scanner.charpos
219
+
220
+ tag_markup = @value.instance_variable_get('@markup')
221
+
222
+ # See https://github.com/Shopify/theme-check/pull/423/files#r701936559 for a detailed explanation
223
+ # of why we're doing the check below.
224
+ #
225
+ # TL;DR it's because line_numbers are not enough to accurately
226
+ # determine the position of the raw markup and because that
227
+ # markup could be present on the same line outside of a Tag. e.g.
228
+ #
229
+ # uhoh {% if uhoh %}
230
+ if (match = /#{@value.tag_name} +#{Regexp.escape(tag_markup)}/.match(source, start))
231
+ return @tag_markup = match[0]
232
+ end
233
+
234
+ # find the markup
235
+ markup_start = source.index(tag_markup, start)
236
+ markup_end = markup_start + tag_markup.size
237
+
238
+ # go back until you find the tag_name
239
+ tag_start = markup_start
240
+ tag_start -= 1 while source[tag_start - 1] =~ WHITESPACE
241
+ tag_start -= @value.tag_name.size
242
+
243
+ # keep track of the error in line_number
244
+ @line_number_offset = source[tag_start...markup_start].count("\n")
245
+
246
+ # return the real raw content
247
+ @tag_markup = source[tag_start...markup_end]
248
+ end
142
249
  end
143
250
  end
@@ -144,6 +144,32 @@ module ThemeCheck
144
144
  corrector = Corrector.new(template: template)
145
145
  correction.call(corrector)
146
146
  end
147
+ rescue => e
148
+ ThemeCheck.bug(<<~EOS)
149
+ Exception while running `Offense#correct`:
150
+ ```
151
+ #{e.class}: #{e.message}
152
+ #{e.backtrace.join("\n ")}
153
+ ```
154
+
155
+ Offense:
156
+ ```
157
+ #{JSON.pretty_generate(to_h)}
158
+ ```
159
+ Check options:
160
+ ```
161
+ #{check.options.pretty_inspect}
162
+ ```
163
+ Markup:
164
+ ```
165
+ #{markup}
166
+ ```
167
+ Node.Markup:
168
+ ```
169
+ #{node&.markup}
170
+ ```
171
+ EOS
172
+ exit(2)
147
173
  end
148
174
 
149
175
  def whole_theme?
@@ -16,22 +16,22 @@ module ThemeCheck
16
16
  @line_number_1_indexed = line_number_1_indexed
17
17
  @node_markup_offset = node_markup_offset
18
18
  @node_markup = node_markup
19
- @strict_position = StrictPosition.new(
20
- needle,
21
- contents,
22
- start_index,
23
- )
24
19
  end
25
20
 
26
21
  def start_line_offset
27
- from_row_column_to_index(contents, line_number, 0)
22
+ @start_line_offset ||= from_row_column_to_index(contents, line_number, 0)
28
23
  end
29
24
 
30
25
  def start_offset
31
- return start_line_offset if @node_markup.nil?
32
- node_markup_start = contents.index(@node_markup, start_line_offset)
33
- return start_line_offset if node_markup_start.nil?
34
- node_markup_start + @node_markup_offset
26
+ @start_offset ||= compute_start_offset
27
+ end
28
+
29
+ def strict_position
30
+ @strict_position ||= StrictPosition.new(
31
+ needle,
32
+ contents,
33
+ start_index,
34
+ )
35
35
  end
36
36
 
37
37
  # 0-indexed, inclusive
@@ -41,39 +41,50 @@ module ThemeCheck
41
41
 
42
42
  # 0-indexed, exclusive
43
43
  def end_index
44
- @strict_position.end_index
44
+ strict_position.end_index
45
45
  end
46
46
 
47
47
  # 0-indexed, inclusive
48
48
  def start_row
49
- @strict_position.start_row
49
+ strict_position.start_row
50
50
  end
51
51
 
52
52
  # 0-indexed, inclusive
53
53
  def start_column
54
- @strict_position.start_column
54
+ strict_position.start_column
55
55
  end
56
56
 
57
57
  # 0-indexed, exclusive (both taken together are) therefore you
58
58
  # might end up on a newline character or the next line
59
59
  def end_row
60
- @strict_position.end_row
60
+ strict_position.end_row
61
61
  end
62
62
 
63
63
  def end_column
64
- @strict_position.end_column
64
+ strict_position.end_column
65
65
  end
66
66
 
67
67
  private
68
68
 
69
+ def compute_start_offset
70
+ return start_line_offset if @node_markup.nil?
71
+ node_markup_start = contents.index(@node_markup, start_line_offset)
72
+ return start_line_offset if node_markup_start.nil?
73
+ node_markup_start + @node_markup_offset
74
+ end
75
+
69
76
  def contents
70
77
  return '' unless @contents.is_a?(String) && !@contents.empty?
71
78
  @contents
72
79
  end
73
80
 
81
+ def content_line_count
82
+ @content_line_count ||= contents.count("\n")
83
+ end
84
+
74
85
  def line_number
75
86
  return 0 if @line_number_1_indexed.nil?
76
- bounded(0, @line_number_1_indexed - 1, contents.lines.size - 1)
87
+ bounded(0, @line_number_1_indexed - 1, content_line_count)
77
88
  end
78
89
 
79
90
  def needle