theme-check 1.1.0 → 1.5.0

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