theme-check 1.2.0 → 1.5.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (75) 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 +47 -0
  5. data/CONTRIBUTING.md +59 -1
  6. data/Gemfile +3 -0
  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/docs/flamegraph.svg +18488 -0
  21. data/lib/theme_check/analyzer.rb +1 -0
  22. data/lib/theme_check/check.rb +2 -2
  23. data/lib/theme_check/checks/app_block_valid_tags.rb +36 -0
  24. data/lib/theme_check/checks/asset_size_css.rb +3 -3
  25. data/lib/theme_check/checks/asset_size_javascript.rb +2 -2
  26. data/lib/theme_check/checks/convert_include_to_render.rb +3 -1
  27. data/lib/theme_check/checks/default_locale.rb +3 -1
  28. data/lib/theme_check/checks/deprecate_bgsizes.rb +1 -1
  29. data/lib/theme_check/checks/deprecate_lazysizes.rb +7 -4
  30. data/lib/theme_check/checks/deprecated_global_app_block_type.rb +57 -0
  31. data/lib/theme_check/checks/img_lazy_loading.rb +1 -1
  32. data/lib/theme_check/checks/img_width_and_height.rb +3 -3
  33. data/lib/theme_check/checks/missing_template.rb +21 -5
  34. data/lib/theme_check/checks/pagination_size.rb +84 -0
  35. data/lib/theme_check/checks/parser_blocking_javascript.rb +1 -1
  36. data/lib/theme_check/checks/remote_asset.rb +3 -3
  37. data/lib/theme_check/checks/space_inside_braces.rb +26 -6
  38. data/lib/theme_check/checks/template_length.rb +1 -1
  39. data/lib/theme_check/checks/undefined_object.rb +1 -1
  40. data/lib/theme_check/checks/valid_html_translation.rb +1 -1
  41. data/lib/theme_check/checks.rb +11 -1
  42. data/lib/theme_check/cli.rb +42 -3
  43. data/lib/theme_check/corrector.rb +9 -0
  44. data/lib/theme_check/file_system_storage.rb +12 -0
  45. data/lib/theme_check/html_check.rb +0 -1
  46. data/lib/theme_check/html_node.rb +37 -16
  47. data/lib/theme_check/html_visitor.rb +17 -3
  48. data/lib/theme_check/json_check.rb +2 -2
  49. data/lib/theme_check/json_file.rb +11 -0
  50. data/lib/theme_check/json_printer.rb +31 -0
  51. data/lib/theme_check/language_server/constants.rb +18 -11
  52. data/lib/theme_check/language_server/document_link_engine.rb +3 -67
  53. data/lib/theme_check/language_server/document_link_provider.rb +71 -0
  54. data/lib/theme_check/language_server/document_link_providers/asset_document_link_provider.rb +11 -0
  55. data/lib/theme_check/language_server/document_link_providers/include_document_link_provider.rb +11 -0
  56. data/lib/theme_check/language_server/document_link_providers/render_document_link_provider.rb +11 -0
  57. data/lib/theme_check/language_server/document_link_providers/section_document_link_provider.rb +11 -0
  58. data/lib/theme_check/language_server/handler.rb +17 -9
  59. data/lib/theme_check/language_server/server.rb +11 -13
  60. data/lib/theme_check/language_server/uri_helper.rb +37 -0
  61. data/lib/theme_check/language_server.rb +6 -0
  62. data/lib/theme_check/node.rb +6 -4
  63. data/lib/theme_check/offense.rb +56 -3
  64. data/lib/theme_check/parsing_helpers.rb +4 -3
  65. data/lib/theme_check/position.rb +98 -14
  66. data/lib/theme_check/printer.rb +9 -5
  67. data/lib/theme_check/regex_helpers.rb +5 -2
  68. data/lib/theme_check/theme.rb +3 -0
  69. data/lib/theme_check/version.rb +1 -1
  70. data/lib/theme_check.rb +1 -0
  71. data/theme-check.gemspec +1 -1
  72. metadata +21 -10
  73. data/bin/liquid-server +0 -4
  74. data/exe/theme-check-language-server.bat +0 -3
  75. data/exe/theme-check.bat +0 -3
@@ -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"
@@ -71,6 +78,15 @@ module ThemeCheck
71
78
  "Print Theme Check version"
72
79
  ) { @command = :version }
73
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
+
74
90
  @option_parser.separator("")
75
91
  @option_parser.separator(<<~EOS)
76
92
  Description:
@@ -165,8 +181,8 @@ module ThemeCheck
165
181
  puts option_parser.to_s
166
182
  end
167
183
 
168
- def check
169
- puts "Checking #{@config.root} ..."
184
+ def check(out_stream = STDOUT)
185
+ STDERR.puts "Checking #{@config.root} ..."
170
186
  storage = ThemeCheck::FileSystemStorage.new(@config.root, ignored_patterns: @config.ignored_patterns)
171
187
  theme = ThemeCheck::Theme.new(storage)
172
188
  if theme.all.empty?
@@ -175,10 +191,33 @@ module ThemeCheck
175
191
  analyzer = ThemeCheck::Analyzer.new(theme, @config.enabled_checks, @config.auto_correct)
176
192
  analyzer.analyze_theme
177
193
  analyzer.correct_offenses
178
- ThemeCheck::Printer.new.print(theme, analyzer.offenses, @config.auto_correct)
194
+ output_with_format(theme, analyzer, out_stream)
179
195
  raise Abort, "" if analyzer.uncorrectable_offenses.any? do |offense|
180
196
  offense.check.severity_value <= Check.severity_value(@fail_level)
181
197
  end
182
198
  end
199
+
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)
215
+ case @format
216
+ when :text
217
+ ThemeCheck::Printer.new(out_stream).print(theme, analyzer.offenses, @config.auto_correct)
218
+ when :json
219
+ ThemeCheck::JsonPrinter.new(out_stream).print(analyzer.offenses)
220
+ end
221
+ end
183
222
  end
184
223
  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,31 @@
1
+ # frozen_string_literal: true
2
+ require 'json'
3
+
4
+ module ThemeCheck
5
+ class JsonPrinter
6
+ def initialize(out_stream = STDOUT)
7
+ @out = out_stream
8
+ end
9
+
10
+ def print(offenses)
11
+ json = offenses_by_path(offenses)
12
+ @out.puts JSON.dump(json)
13
+ end
14
+
15
+ def offenses_by_path(offenses)
16
+ offenses
17
+ .map(&:to_h)
18
+ .group_by { |offense| offense[:path] }
19
+ .map do |(path, path_offenses)|
20
+ {
21
+ path: path,
22
+ offenses: path_offenses.map { |offense| offense.filter { |k, _v| k != :path } },
23
+ errorCount: path_offenses.count { |offense| offense[:severity] == Check::SEVERITY_VALUES[:error] },
24
+ suggestionCount: path_offenses.count { |offense| offense[:severity] == Check::SEVERITY_VALUES[:suggestion] },
25
+ styleCount: path_offenses.count { |offense| offense[:severity] == Check::SEVERITY_VALUES[:style] },
26
+ }
27
+ end
28
+ .sort_by { |o| o[:path] }
29
+ end
30
+ end
31
+ 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
@@ -3,81 +3,17 @@
3
3
  module ThemeCheck
4
4
  module LanguageServer
5
5
  class DocumentLinkEngine
6
- include PositionHelper
7
- include RegexHelpers
8
-
9
6
  def initialize(storage)
10
7
  @storage = storage
8
+ @providers = DocumentLinkProvider.all.map { |x| x.new(storage) }
11
9
  end
12
10
 
13
11
  def document_links(relative_path)
14
12
  buffer = @storage.read(relative_path)
15
13
  return [] unless buffer
16
- snippet_matches = matches(buffer, PARTIAL_RENDER).map do |match|
17
- start_line, start_character = from_index_to_row_column(
18
- buffer,
19
- match.begin(:partial),
20
- )
21
-
22
- end_line, end_character = from_index_to_row_column(
23
- buffer,
24
- match.end(:partial)
25
- )
26
-
27
- {
28
- target: snippet_link(match[:partial]),
29
- range: {
30
- start: {
31
- line: start_line,
32
- character: start_character,
33
- },
34
- end: {
35
- line: end_line,
36
- character: end_character,
37
- },
38
- },
39
- }
14
+ @providers.flat_map do |p|
15
+ p.document_links(buffer)
40
16
  end
41
- asset_matches = matches(buffer, ASSET_INCLUDE).map do |match|
42
- start_line, start_character = from_index_to_row_column(
43
- buffer,
44
- match.begin(:partial),
45
- )
46
-
47
- end_line, end_character = from_index_to_row_column(
48
- buffer,
49
- match.end(:partial)
50
- )
51
-
52
- {
53
- target: asset_link(match[:partial]),
54
- range: {
55
- start: {
56
- line: start_line,
57
- character: start_character,
58
- },
59
- end: {
60
- line: end_line,
61
- character: end_character,
62
- },
63
- },
64
- }
65
- end
66
- snippet_matches + asset_matches
67
- end
68
-
69
- def snippet_link(partial)
70
- file_link('snippets', partial, '.liquid')
71
- end
72
-
73
- def asset_link(partial)
74
- file_link('assets', partial, '')
75
- end
76
-
77
- private
78
-
79
- def file_link(directory, partial, extension)
80
- "file://#{@storage.path(directory + '/' + partial + extension)}"
81
17
  end
82
18
  end
83
19
  end