theme-check 1.1.0 → 1.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +5 -9
  3. data/.gitignore +1 -0
  4. data/CHANGELOG.md +50 -0
  5. data/CONTRIBUTING.md +1 -1
  6. data/RELEASING.md +34 -2
  7. data/bin/theme-check +29 -0
  8. data/bin/theme-check-language-server +29 -0
  9. data/config/default.yml +15 -1
  10. data/config/theme_app_extension.yml +15 -0
  11. data/data/shopify_liquid/objects.yml +1 -0
  12. data/docs/checks/app_block_valid_tags.md +40 -0
  13. data/docs/checks/asset_size_app_block_css.md +1 -1
  14. data/docs/checks/deprecate_lazysizes.md +0 -3
  15. data/docs/checks/deprecated_global_app_block_type.md +65 -0
  16. data/docs/checks/missing_template.md +25 -0
  17. data/docs/checks/pagination_size.md +44 -0
  18. data/docs/checks/template_length.md +1 -1
  19. data/docs/checks/undefined_object.md +5 -0
  20. data/lib/theme_check/analyzer.rb +1 -0
  21. data/lib/theme_check/check.rb +3 -3
  22. data/lib/theme_check/checks/app_block_valid_tags.rb +36 -0
  23. data/lib/theme_check/checks/asset_size_css.rb +3 -3
  24. data/lib/theme_check/checks/asset_size_javascript.rb +2 -2
  25. data/lib/theme_check/checks/convert_include_to_render.rb +3 -1
  26. data/lib/theme_check/checks/default_locale.rb +3 -1
  27. data/lib/theme_check/checks/deprecate_bgsizes.rb +1 -1
  28. data/lib/theme_check/checks/deprecate_lazysizes.rb +7 -4
  29. data/lib/theme_check/checks/deprecated_global_app_block_type.rb +57 -0
  30. data/lib/theme_check/checks/img_lazy_loading.rb +1 -1
  31. data/lib/theme_check/checks/img_width_and_height.rb +3 -3
  32. data/lib/theme_check/checks/missing_template.rb +21 -5
  33. data/lib/theme_check/checks/pagination_size.rb +64 -0
  34. data/lib/theme_check/checks/parser_blocking_javascript.rb +1 -1
  35. data/lib/theme_check/checks/remote_asset.rb +3 -3
  36. data/lib/theme_check/checks/space_inside_braces.rb +27 -7
  37. data/lib/theme_check/checks/template_length.rb +1 -1
  38. data/lib/theme_check/checks/undefined_object.rb +1 -1
  39. data/lib/theme_check/checks/valid_html_translation.rb +1 -1
  40. data/lib/theme_check/checks.rb +11 -1
  41. data/lib/theme_check/cli.rb +18 -2
  42. data/lib/theme_check/corrector.rb +9 -0
  43. data/lib/theme_check/file_system_storage.rb +12 -0
  44. data/lib/theme_check/html_check.rb +0 -1
  45. data/lib/theme_check/html_node.rb +37 -16
  46. data/lib/theme_check/html_visitor.rb +17 -3
  47. data/lib/theme_check/json_check.rb +2 -2
  48. data/lib/theme_check/json_file.rb +11 -0
  49. data/lib/theme_check/json_printer.rb +27 -0
  50. data/lib/theme_check/language_server/constants.rb +18 -11
  51. data/lib/theme_check/language_server/document_link_engine.rb +3 -67
  52. data/lib/theme_check/language_server/document_link_provider.rb +71 -0
  53. data/lib/theme_check/language_server/document_link_providers/asset_document_link_provider.rb +11 -0
  54. data/lib/theme_check/language_server/document_link_providers/include_document_link_provider.rb +11 -0
  55. data/lib/theme_check/language_server/document_link_providers/render_document_link_provider.rb +11 -0
  56. data/lib/theme_check/language_server/document_link_providers/section_document_link_provider.rb +11 -0
  57. data/lib/theme_check/language_server/handler.rb +17 -9
  58. data/lib/theme_check/language_server/server.rb +9 -0
  59. data/lib/theme_check/language_server/uri_helper.rb +37 -0
  60. data/lib/theme_check/language_server.rb +6 -0
  61. data/lib/theme_check/node.rb +6 -4
  62. data/lib/theme_check/offense.rb +56 -3
  63. data/lib/theme_check/parsing_helpers.rb +4 -3
  64. data/lib/theme_check/position.rb +98 -14
  65. data/lib/theme_check/regex_helpers.rb +5 -2
  66. data/lib/theme_check/theme.rb +3 -0
  67. data/lib/theme_check/version.rb +1 -1
  68. data/lib/theme_check.rb +1 -0
  69. data/theme-check.gemspec +1 -1
  70. metadata +20 -6
  71. data/bin/liquid-server +0 -4
@@ -8,7 +8,7 @@ module ThemeCheck
8
8
 
9
9
  def on_script(node)
10
10
  return unless node.attributes["src"]
11
- return if node.attributes["defer"] || node.attributes["async"] || node.attributes["type"]&.value == "module"
11
+ return if node.attributes["defer"] || node.attributes["async"] || node.attributes["type"] == "module"
12
12
 
13
13
  add_offense("Missing async or defer attribute on script tag", node: node)
14
14
  end
@@ -14,7 +14,7 @@ module ThemeCheck
14
14
  def on_element(node)
15
15
  return unless TAGS.include?(node.name)
16
16
 
17
- resource_url = node.attributes["src"]&.value || node.attributes["href"]&.value
17
+ resource_url = node.attributes["src"] || node.attributes["href"]
18
18
  return if resource_url.nil? || resource_url.empty?
19
19
 
20
20
  # Ignore if URL is Liquid, taken care of by AssetUrlFilters check
@@ -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.value != "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.",
@@ -14,18 +14,38 @@ module ThemeCheck
14
14
  return unless node.markup
15
15
  return if :assign == node.type_name
16
16
 
17
- outside_of_strings(node.markup) do |chunk|
17
+ outside_of_strings(node.markup) do |chunk, chunk_start|
18
18
  chunk.scan(/([,:|]|==|<>|<=|>=|<|>|!=) +/) do |_match|
19
- add_offense("Too many spaces after '#{Regexp.last_match(1)}'", node: node, markup: Regexp.last_match(0))
19
+ add_offense(
20
+ "Too many spaces after '#{Regexp.last_match(1)}'",
21
+ node: node,
22
+ markup: Regexp.last_match(0),
23
+ node_markup_offset: chunk_start + Regexp.last_match.begin(0)
24
+ )
20
25
  end
21
26
  chunk.scan(/([,:|]|==|<>|<=|>=|<\b|>\b|!=)(\S|\z)/) do |_match|
22
- add_offense("Space missing after '#{Regexp.last_match(1)}'", node: node, markup: Regexp.last_match(0))
27
+ add_offense(
28
+ "Space missing after '#{Regexp.last_match(1)}'",
29
+ node: node,
30
+ markup: Regexp.last_match(0),
31
+ node_markup_offset: chunk_start + Regexp.last_match.begin(0),
32
+ )
23
33
  end
24
34
  chunk.scan(/ (\||==|<>|<=|>=|<|>|!=)+/) do |_match|
25
- add_offense("Too many spaces before '#{Regexp.last_match(1)}'", node: node, markup: Regexp.last_match(0))
35
+ add_offense(
36
+ "Too many spaces before '#{Regexp.last_match(1)}'",
37
+ node: node,
38
+ markup: Regexp.last_match(0),
39
+ node_markup_offset: chunk_start + Regexp.last_match.begin(0)
40
+ )
26
41
  end
27
42
  chunk.scan(/(\A|\S)(?<match>\||==|<>|<=|>=|<|\b>|!=)/) do |_match|
28
- add_offense("Space missing before '#{Regexp.last_match(1)}'", node: node, markup: Regexp.last_match(0))
43
+ add_offense(
44
+ "Space missing before '#{Regexp.last_match(1)}'",
45
+ node: node,
46
+ markup: Regexp.last_match(0),
47
+ node_markup_offset: chunk_start + Regexp.last_match.begin(0)
48
+ )
29
49
  end
30
50
  end
31
51
  end
@@ -51,13 +71,13 @@ module ThemeCheck
51
71
  end
52
72
 
53
73
  def on_variable(node)
54
- return if @ignore
74
+ return if @ignore || node.markup.empty?
55
75
  if node.markup[0] != " "
56
76
  add_offense("Space missing after '{{'", node: node) do |corrector|
57
77
  corrector.insert_before(node, " ")
58
78
  end
59
79
  end
60
- if node.markup[-1] != " "
80
+ if node.markup[-1] != " " && node.markup[-1] != "\n"
61
81
  add_offense("Space missing before '}}'", node: node) do |corrector|
62
82
  corrector.insert_after(node, " ")
63
83
  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
@@ -55,7 +55,7 @@ module ThemeCheck
55
55
  end
56
56
  end
57
57
 
58
- def initialize(exclude_snippets: false)
58
+ def initialize(exclude_snippets: true)
59
59
  @exclude_snippets = exclude_snippets
60
60
  @files = {}
61
61
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'nokogumbo'
3
+ require 'nokogiri'
4
4
 
5
5
  module ThemeCheck
6
6
  class ValidHTMLTranslation < JsonCheck
@@ -29,8 +29,18 @@ module ThemeCheck
29
29
  def call_check_method(check, method, *args)
30
30
  return unless check.respond_to?(method) && !check.ignored?
31
31
 
32
- Timeout.timeout(CHECK_METHOD_TIMEOUT) do
32
+ # If you want to use binding.pry in unit tests, define the
33
+ # THEME_CHECK_DEBUG environment variable. e.g.
34
+ #
35
+ # $ export THEME_CHECK_DEBUG=true
36
+ # $ bundle exec rake tests:in_memory
37
+ #
38
+ if ENV['THEME_CHECK_DEBUG']
33
39
  check.send(method, *args)
40
+ else
41
+ Timeout.timeout(CHECK_METHOD_TIMEOUT) do
42
+ check.send(method, *args)
43
+ end
34
44
  end
35
45
  rescue Liquid::Error
36
46
  # Pass-through Liquid errors
@@ -5,6 +5,8 @@ module ThemeCheck
5
5
  class Cli
6
6
  class Abort < StandardError; end
7
7
 
8
+ FORMATS = [:text, :json]
9
+
8
10
  attr_accessor :path
9
11
 
10
12
  def initialize
@@ -15,6 +17,7 @@ module ThemeCheck
15
17
  @auto_correct = false
16
18
  @config_path = nil
17
19
  @fail_level = :error
20
+ @format = :text
18
21
  end
19
22
 
20
23
  def option_parser(parser = OptionParser.new, help: true)
@@ -29,6 +32,10 @@ module ThemeCheck
29
32
  "Use the config provided, overriding .theme-check.yml if present",
30
33
  "Use :theme_app_extension to use default checks for theme app extensions"
31
34
  ) { |path| @config_path = path }
35
+ @option_parser.on(
36
+ "-o", "--output FORMAT", FORMATS,
37
+ "The output format to use. (text|json, default: text)"
38
+ ) { |format| @format = format.to_sym }
32
39
  @option_parser.on(
33
40
  "-c", "--category CATEGORY", Check::CATEGORIES, "Only run this category of checks",
34
41
  "Runs checks matching all categories when specified more than once"
@@ -166,7 +173,7 @@ module ThemeCheck
166
173
  end
167
174
 
168
175
  def check
169
- puts "Checking #{@config.root} ..."
176
+ STDERR.puts "Checking #{@config.root} ..."
170
177
  storage = ThemeCheck::FileSystemStorage.new(@config.root, ignored_patterns: @config.ignored_patterns)
171
178
  theme = ThemeCheck::Theme.new(storage)
172
179
  if theme.all.empty?
@@ -175,10 +182,19 @@ module ThemeCheck
175
182
  analyzer = ThemeCheck::Analyzer.new(theme, @config.enabled_checks, @config.auto_correct)
176
183
  analyzer.analyze_theme
177
184
  analyzer.correct_offenses
178
- ThemeCheck::Printer.new.print(theme, analyzer.offenses, @config.auto_correct)
185
+ output_with_format(theme, analyzer)
179
186
  raise Abort, "" if analyzer.uncorrectable_offenses.any? do |offense|
180
187
  offense.check.severity_value <= Check.severity_value(@fail_level)
181
188
  end
182
189
  end
190
+
191
+ def output_with_format(theme, analyzer)
192
+ case @format
193
+ when :text
194
+ ThemeCheck::Printer.new.print(theme, analyzer.offenses, @config.auto_correct)
195
+ when :json
196
+ ThemeCheck::JsonPrinter.new.print(analyzer.offenses)
197
+ end
198
+ end
183
199
  end
184
200
  end
@@ -27,5 +27,14 @@ module ThemeCheck
27
27
  line.insert(node.range[0], insert_before)
28
28
  line.insert(node.range[1] + 1 + insert_before.length, insert_after)
29
29
  end
30
+
31
+ def create(theme, relative_path, content)
32
+ theme.storage.write(relative_path, content)
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
30
39
  end
31
40
  end
@@ -20,6 +20,9 @@ module ThemeCheck
20
20
  end
21
21
 
22
22
  def write(relative_path, content)
23
+ reset_memoizers unless file_exists?(relative_path)
24
+
25
+ file(relative_path).dirname.mkpath unless file(relative_path).dirname.directory?
23
26
  file(relative_path).write(content)
24
27
  end
25
28
 
@@ -36,6 +39,15 @@ module ThemeCheck
36
39
 
37
40
  private
38
41
 
42
+ def file_exists?(relative_path)
43
+ !!@files[relative_path]
44
+ end
45
+
46
+ def reset_memoizers
47
+ @file_array = nil
48
+ @directories = nil
49
+ end
50
+
39
51
  def glob(pattern)
40
52
  @root.glob(pattern).reject do |path|
41
53
  relative_path = path.relative_path_from(@root)
@@ -3,7 +3,6 @@
3
3
  module ThemeCheck
4
4
  class HtmlCheck < Check
5
5
  extend ChecksTracking
6
- VARIABLE = /#{Liquid::VariableStart}.*?#{Liquid::VariableEnd}/om
7
6
  START_OR_END_QUOTE = /(^['"])|(['"]$)/
8
7
  end
9
8
  end
@@ -4,13 +4,14 @@ require "forwardable"
4
4
  module ThemeCheck
5
5
  class HtmlNode
6
6
  extend Forwardable
7
- attr_reader :template
7
+ include RegexHelpers
8
+ attr_reader :template, :parent
8
9
 
9
- def_delegators :@value, :content, :attributes
10
-
11
- def initialize(value, template)
10
+ def initialize(value, template, placeholder_values = [], parent = nil)
12
11
  @value = value
13
12
  @template = template
13
+ @placeholder_values = placeholder_values
14
+ @parent = parent
14
15
  end
15
16
 
16
17
  def literal?
@@ -22,35 +23,55 @@ module ThemeCheck
22
23
  end
23
24
 
24
25
  def children
25
- @value.children.map { |child| HtmlNode.new(child, template) }
26
+ @children ||= @value
27
+ .children
28
+ .map { |child| HtmlNode.new(child, template, @placeholder_values, self) }
26
29
  end
27
30
 
28
- def parent
29
- HtmlNode.new(@value.parent, template)
31
+ def attributes
32
+ @attributes ||= @value.attributes
33
+ .map { |k, v| [replace_placeholders(k), replace_placeholders(v.value)] }
34
+ .to_h
30
35
  end
31
36
 
32
- def name
33
- if @value.name == "#document-fragment"
34
- "document"
35
- else
36
- @value.name
37
- end
37
+ def content
38
+ @content ||= replace_placeholders(@value.content)
38
39
  end
39
40
 
41
+ # @value is not forwarded because we _need_ to replace the
42
+ # placeholders for the HtmlNode to make sense.
40
43
  def value
41
44
  if literal?
42
- @value.content
45
+ content
43
46
  else
44
- @value
47
+ markup
48
+ end
49
+ end
50
+
51
+ def name
52
+ if @value.name == "#document-fragment"
53
+ "document"
54
+ else
55
+ @value.name
45
56
  end
46
57
  end
47
58
 
48
59
  def markup
49
- @value.to_html
60
+ @markup ||= replace_placeholders(@value.to_html)
50
61
  end
51
62
 
52
63
  def line_number
53
64
  @value.line
54
65
  end
66
+
67
+ private
68
+
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]
74
+ end
75
+ end
55
76
  end
56
77
  end
@@ -1,18 +1,20 @@
1
1
  # frozen_string_literal: true
2
- require "nokogumbo"
2
+ require "nokogiri"
3
3
  require "forwardable"
4
4
 
5
5
  module ThemeCheck
6
6
  class HtmlVisitor
7
+ include RegexHelpers
7
8
  attr_reader :checks
8
9
 
9
10
  def initialize(checks)
10
11
  @checks = checks
12
+ @placeholder_values = []
11
13
  end
12
14
 
13
15
  def visit_template(template)
14
16
  doc = parse(template)
15
- visit(HtmlNode.new(doc, template))
17
+ visit(HtmlNode.new(doc, template, @placeholder_values))
16
18
  rescue ArgumentError => e
17
19
  call_checks(:on_parse_error, e, template)
18
20
  end
@@ -20,7 +22,19 @@ module ThemeCheck
20
22
  private
21
23
 
22
24
  def parse(template)
23
- Nokogiri::HTML5.fragment(template.source, max_tree_depth: 400, max_attributes: 400)
25
+ parseable_source = +template.source.clone
26
+
27
+ # Replace all liquid tags with {%#{i}######%} to prevent the HTML
28
+ # parser from freaking out. We transparently replace those placeholders in
29
+ # HtmlNode.
30
+ matches(parseable_source, LIQUID_TAG_OR_VARIABLE).each do |m|
31
+ 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, '#')}%}"
35
+ end
36
+
37
+ Nokogiri::HTML5.fragment(parseable_source, max_tree_depth: 400, max_attributes: 400)
24
38
  end
25
39
 
26
40
  def visit(node)
@@ -4,8 +4,8 @@ module ThemeCheck
4
4
  class JsonCheck < Check
5
5
  extend ChecksTracking
6
6
 
7
- def add_offense(message, markup: nil, line_number: nil, template: nil)
8
- offenses << Offense.new(check: self, message: message, markup: markup, line_number: line_number, template: template)
7
+ def add_offense(message, markup: nil, line_number: nil, template: nil, &block)
8
+ offenses << Offense.new(check: self, message: message, markup: markup, line_number: line_number, template: template, correction: block)
9
9
  end
10
10
  end
11
11
  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
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+ require 'json'
3
+
4
+ module ThemeCheck
5
+ class JsonPrinter
6
+ def print(offenses)
7
+ json = offenses_by_path(offenses)
8
+ puts JSON.dump(json)
9
+ end
10
+
11
+ def offenses_by_path(offenses)
12
+ offenses
13
+ .map(&:to_h)
14
+ .group_by { |offense| offense[:path] }
15
+ .map do |(path, path_offenses)|
16
+ {
17
+ path: path,
18
+ offenses: path_offenses.map { |offense| offense.filter { |k, _v| k != :path } },
19
+ errorCount: path_offenses.count { |offense| offense[:severity] == Check::SEVERITY_VALUES[:error] },
20
+ suggestionCount: path_offenses.count { |offense| offense[:severity] == Check::SEVERITY_VALUES[:suggestion] },
21
+ styleCount: path_offenses.count { |offense| offense[:severity] == Check::SEVERITY_VALUES[:style] },
22
+ }
23
+ end
24
+ .sort_by { |o| o[:path] }
25
+ end
26
+ end
27
+ end
@@ -2,21 +2,28 @@
2
2
 
3
3
  module ThemeCheck
4
4
  module LanguageServer
5
- PARTIAL_RENDER = %r{
6
- \{\%-?\s*render\s+'(?<partial>[^']*)'|
7
- \{\%-?\s*render\s+"(?<partial>[^"]*)"|
5
+ def self.partial_tag(tag)
6
+ %r{
7
+ \{\%-?\s*#{tag}\s+'(?<partial>[^']*)'|
8
+ \{\%-?\s*#{tag}\s+"(?<partial>[^"]*)"|
9
+
10
+ # in liquid tags the whole line is white space until the tag
11
+ ^\s*#{tag}\s+'(?<partial>[^']*)'|
12
+ ^\s*#{tag}\s+"(?<partial>[^"]*)"
13
+ }mix
14
+ end
15
+
16
+ PARTIAL_RENDER = partial_tag('render')
17
+ PARTIAL_INCLUDE = partial_tag('include')
18
+ PARTIAL_SECTION = partial_tag('section')
8
19
 
9
- # in liquid tags the whole line is white space until render
10
- ^\s*render\s+'(?<partial>[^']*)'|
11
- ^\s*render\s+"(?<partial>[^"]*)"
12
- }mix
13
20
  ASSET_INCLUDE = %r{
14
- \{\%-?\s*'(?<partial>[^']*)'\s*\|\s*asset_url|
15
- \{\%-?\s*"(?<partial>[^"]*)"\s*\|\s*asset_url|
21
+ \{\{-?\s*'(?<partial>[^']*)'\s*\|\s*asset_url|
22
+ \{\{-?\s*"(?<partial>[^"]*)"\s*\|\s*asset_url|
16
23
 
17
24
  # in liquid tags the whole line is white space until the asset partial
18
- ^\s*'(?<partial>[^']*)'\s*\|\s*asset_url|
19
- ^\s*"(?<partial>[^"]*)"\s*\|\s*asset_url
25
+ ^\s*(?:echo|assign[^=]*\=)\s*'(?<partial>[^']*)'\s*\|\s*asset_url|
26
+ ^\s*(?:echo|assign[^=]*\=)\s*"(?<partial>[^"]*)"\s*\|\s*asset_url
20
27
  }mix
21
28
  end
22
29
  end