theme-check 0.5.0 → 0.7.3

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +10 -3
  3. data/.rubocop.yml +6 -3
  4. data/CHANGELOG.md +35 -0
  5. data/Gemfile +5 -3
  6. data/LICENSE.md +2 -0
  7. data/README.md +3 -0
  8. data/RELEASING.md +10 -3
  9. data/Rakefile +6 -0
  10. data/config/default.yml +11 -1
  11. data/data/shopify_translation_keys.yml +850 -0
  12. data/docs/checks/asset_size_css.md +52 -0
  13. data/docs/checks/img_width_and_height.md +79 -0
  14. data/docs/checks/parser_blocking_javascript.md +3 -3
  15. data/docs/checks/remote_asset.md +82 -0
  16. data/exe/theme-check +1 -1
  17. data/lib/theme_check.rb +1 -0
  18. data/lib/theme_check/check.rb +1 -1
  19. data/lib/theme_check/checks/asset_size_css.rb +89 -0
  20. data/lib/theme_check/checks/asset_size_javascript.rb +2 -8
  21. data/lib/theme_check/checks/img_width_and_height.rb +74 -0
  22. data/lib/theme_check/checks/matching_translations.rb +1 -1
  23. data/lib/theme_check/checks/parser_blocking_javascript.rb +6 -14
  24. data/lib/theme_check/checks/remote_asset.rb +99 -0
  25. data/lib/theme_check/checks/translation_key_exists.rb +13 -1
  26. data/lib/theme_check/checks/undefined_object.rb +1 -1
  27. data/lib/theme_check/checks/valid_html_translation.rb +1 -1
  28. data/lib/theme_check/cli.rb +106 -51
  29. data/lib/theme_check/config.rb +3 -0
  30. data/lib/theme_check/disabled_checks.rb +2 -2
  31. data/lib/theme_check/in_memory_storage.rb +13 -8
  32. data/lib/theme_check/language_server.rb +2 -0
  33. data/lib/theme_check/language_server/completion_engine.rb +3 -3
  34. data/lib/theme_check/language_server/completion_provider.rb +4 -0
  35. data/lib/theme_check/language_server/completion_providers/filter_completion_provider.rb +6 -2
  36. data/lib/theme_check/language_server/completion_providers/object_completion_provider.rb +1 -1
  37. data/lib/theme_check/language_server/completion_providers/render_snippet_completion_provider.rb +43 -0
  38. data/lib/theme_check/language_server/completion_providers/tag_completion_provider.rb +2 -2
  39. data/lib/theme_check/language_server/constants.rb +10 -0
  40. data/lib/theme_check/language_server/document_link_engine.rb +48 -0
  41. data/lib/theme_check/language_server/handler.rb +56 -17
  42. data/lib/theme_check/language_server/server.rb +4 -4
  43. data/lib/theme_check/liquid_check.rb +11 -0
  44. data/lib/theme_check/node.rb +1 -2
  45. data/lib/theme_check/offense.rb +3 -1
  46. data/lib/theme_check/packager.rb +1 -1
  47. data/lib/theme_check/releaser.rb +39 -0
  48. data/lib/theme_check/remote_asset_file.rb +1 -1
  49. data/lib/theme_check/shopify_liquid/deprecated_filter.rb +10 -8
  50. data/lib/theme_check/shopify_liquid/filter.rb +3 -5
  51. data/lib/theme_check/shopify_liquid/object.rb +2 -6
  52. data/lib/theme_check/shopify_liquid/tag.rb +1 -3
  53. data/lib/theme_check/storage.rb +3 -3
  54. data/lib/theme_check/string_helpers.rb +47 -0
  55. data/lib/theme_check/tags.rb +1 -2
  56. data/lib/theme_check/theme.rb +1 -1
  57. data/lib/theme_check/version.rb +1 -1
  58. data/packaging/homebrew/theme_check.base.rb +1 -1
  59. data/theme-check.gemspec +1 -2
  60. metadata +16 -18
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  require_relative "language_server/protocol"
3
+ require_relative "language_server/constants"
3
4
  require_relative "language_server/handler"
4
5
  require_relative "language_server/server"
5
6
  require_relative "language_server/tokens"
@@ -7,6 +8,7 @@ require_relative "language_server/position_helper"
7
8
  require_relative "language_server/completion_helper"
8
9
  require_relative "language_server/completion_provider"
9
10
  require_relative "language_server/completion_engine"
11
+ require_relative "language_server/document_link_engine"
10
12
 
11
13
  Dir[__dir__ + "/language_server/completion_providers/*.rb"].each do |file|
12
14
  require file
@@ -7,11 +7,11 @@ module ThemeCheck
7
7
 
8
8
  def initialize(storage)
9
9
  @storage = storage
10
- @providers = CompletionProvider.all.map(&:new)
10
+ @providers = CompletionProvider.all.map { |x| x.new(storage) }
11
11
  end
12
12
 
13
- def completions(name, line, col)
14
- buffer = @storage.read(name)
13
+ def completions(relative_path, line, col)
14
+ buffer = @storage.read(relative_path)
15
15
  cursor = from_line_column_to_index(buffer, line, col)
16
16
  token = find_token(buffer, cursor)
17
17
  return [] if token.nil?
@@ -16,6 +16,10 @@ module ThemeCheck
16
16
  end
17
17
  end
18
18
 
19
+ def initialize(storage = InMemoryStorage.new)
20
+ @storage = storage
21
+ end
22
+
19
23
  def completions(content, cursor)
20
24
  raise NotImplementedError
21
25
  end
@@ -7,8 +7,8 @@ module ThemeCheck
7
7
 
8
8
  def completions(content, cursor)
9
9
  return [] unless can_complete?(content, cursor)
10
- ShopifyLiquid::Filter.labels
11
- .select { |w| w.starts_with?(partial(content, cursor)) }
10
+ available_labels
11
+ .select { |w| w.start_with?(partial(content, cursor)) }
12
12
  .map { |filter| filter_to_completion(filter) }
13
13
  end
14
14
 
@@ -21,6 +21,10 @@ module ThemeCheck
21
21
 
22
22
  private
23
23
 
24
+ def available_labels
25
+ @labels ||= ShopifyLiquid::Filter.labels - ShopifyLiquid::DeprecatedFilter.labels
26
+ end
27
+
24
28
  def cursor_on_filter?(content, cursor)
25
29
  return false unless content.match?(NAMED_FILTER)
26
30
  matches(content, NAMED_FILTER).any? do |match|
@@ -7,7 +7,7 @@ module ThemeCheck
7
7
  return [] unless can_complete?(content, cursor)
8
8
  partial = first_word(content) || ''
9
9
  ShopifyLiquid::Object.labels
10
- .select { |w| w.starts_with?(partial) }
10
+ .select { |w| w.start_with?(partial) }
11
11
  .map { |object| object_to_completion(object) }
12
12
  end
13
13
 
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class RenderSnippetCompletionProvider < CompletionProvider
6
+ def completions(content, cursor)
7
+ return [] unless cursor_on_quoted_argument?(content, cursor)
8
+ partial = snippet(content) || ''
9
+ snippets
10
+ .select { |x| x.start_with?(partial) }
11
+ .map { |x| snippet_to_completion(x) }
12
+ end
13
+
14
+ private
15
+
16
+ def cursor_on_quoted_argument?(content, cursor)
17
+ match = content.match(PARTIAL_RENDER)
18
+ return false if match.nil?
19
+ match.begin(:partial) <= cursor && cursor <= match.end(:partial)
20
+ end
21
+
22
+ def snippet(content)
23
+ match = content.match(PARTIAL_RENDER)
24
+ return if match.nil?
25
+ match[:partial]
26
+ end
27
+
28
+ def snippets
29
+ @storage
30
+ .files
31
+ .select { |x| x.include?('snippets/') }
32
+ end
33
+
34
+ def snippet_to_completion(file)
35
+ {
36
+ label: File.basename(file, '.liquid'),
37
+ kind: CompletionItemKinds::SNIPPET,
38
+ detail: file,
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -7,12 +7,12 @@ module ThemeCheck
7
7
  return [] unless can_complete?(content, cursor)
8
8
  partial = first_word(content) || ''
9
9
  ShopifyLiquid::Tag.labels
10
- .select { |w| w.starts_with?(partial) }
10
+ .select { |w| w.start_with?(partial) }
11
11
  .map { |tag| tag_to_completion(tag) }
12
12
  end
13
13
 
14
14
  def can_complete?(content, cursor)
15
- content.starts_with?(Liquid::TagStart) && (
15
+ content.start_with?(Liquid::TagStart) && (
16
16
  cursor_on_first_word?(content, cursor) ||
17
17
  cursor_on_start_content?(content, cursor, Liquid::TagStart)
18
18
  )
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ PARTIAL_RENDER = %r{
6
+ \{\%-?\s*render\s+'(?<partial>[^']*)'|
7
+ \{\%-?\s*render\s+"(?<partial>[^"]*)"
8
+ }mix
9
+ end
10
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class DocumentLinkEngine
6
+ include PositionHelper
7
+ include RegexHelpers
8
+
9
+ def initialize(storage)
10
+ @storage = storage
11
+ end
12
+
13
+ def document_links(relative_path)
14
+ buffer = @storage.read(relative_path)
15
+ return [] unless buffer
16
+ matches(buffer, PARTIAL_RENDER).map do |match|
17
+ start_line, start_character = from_index_to_line_column(
18
+ buffer,
19
+ match.begin(:partial),
20
+ )
21
+
22
+ end_line, end_character = from_index_to_line_column(
23
+ buffer,
24
+ match.end(:partial)
25
+ )
26
+
27
+ {
28
+ target: 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
+ }
40
+ end
41
+ end
42
+
43
+ def link(partial)
44
+ "file://#{@storage.path('snippets/' + partial + '.liquid')}"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -8,6 +8,7 @@ module ThemeCheck
8
8
  triggerCharacters: ['.', '{{ ', '{% '],
9
9
  context: true,
10
10
  },
11
+ documentLinkProvider: true,
11
12
  textDocumentSync: {
12
13
  openClose: true,
13
14
  change: TextDocumentSyncKind::FULL,
@@ -19,12 +20,13 @@ module ThemeCheck
19
20
  def initialize(server)
20
21
  @server = server
21
22
  @previously_reported_files = Set.new
22
- @storage = InMemoryStorage.new
23
- @completion_engine = CompletionEngine.new(@storage)
24
23
  end
25
24
 
26
25
  def on_initialize(id, params)
27
26
  @root_path = params["rootPath"]
27
+ @storage = in_memory_storage(@root_path)
28
+ @completion_engine = CompletionEngine.new(@storage)
29
+ @document_link_engine = DocumentLinkEngine.new(@storage)
28
30
  # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#responseMessage
29
31
  send_response(
30
32
  id: id,
@@ -40,41 +42,70 @@ module ThemeCheck
40
42
  alias_method :on_shutdown, :on_exit
41
43
 
42
44
  def on_text_document_did_change(_id, params)
43
- uri = text_document_uri(params)
44
- @storage.write(uri, content_changes_text(params))
45
+ relative_path = relative_path_from_text_document_uri(params)
46
+ @storage.write(relative_path, content_changes_text(params))
45
47
  end
46
48
 
47
49
  def on_text_document_did_close(_id, params)
48
- uri = text_document_uri(params)
49
- @storage.write(uri, nil)
50
+ relative_path = relative_path_from_text_document_uri(params)
51
+ @storage.write(relative_path, "")
50
52
  end
51
53
 
52
54
  def on_text_document_did_open(_id, params)
53
- uri = text_document_uri(params)
54
- @storage.write(uri, text_document_text(params))
55
- analyze_and_send_offenses(uri)
55
+ relative_path = relative_path_from_text_document_uri(params)
56
+ @storage.write(relative_path, text_document_text(params))
57
+ analyze_and_send_offenses(text_document_uri(params))
56
58
  end
57
59
 
58
60
  def on_text_document_did_save(_id, params)
59
61
  analyze_and_send_offenses(text_document_uri(params))
60
62
  end
61
63
 
64
+ def on_text_document_document_link(id, params)
65
+ relative_path = relative_path_from_text_document_uri(params)
66
+ send_response(
67
+ id: id,
68
+ result: document_links(relative_path)
69
+ )
70
+ end
71
+
62
72
  def on_text_document_completion(id, params)
63
- uri = text_document_uri(params)
73
+ relative_path = relative_path_from_text_document_uri(params)
64
74
  line = params.dig('position', 'line')
65
75
  col = params.dig('position', 'character')
66
76
  send_response(
67
77
  id: id,
68
- result: completions(uri, line, col)
78
+ result: completions(relative_path, line, col)
69
79
  )
70
80
  end
71
81
 
72
82
  private
73
83
 
84
+ def in_memory_storage(root)
85
+ config = config_for_path(root)
86
+
87
+ # Make a real FS to get the files from the snippets folder
88
+ fs = ThemeCheck::FileSystemStorage.new(
89
+ config.root,
90
+ ignored_patterns: config.ignored_patterns
91
+ )
92
+
93
+ # Turn that into a hash of empty buffers
94
+ files = fs.files
95
+ .map { |fn| [fn, ""] }
96
+ .to_h
97
+
98
+ InMemoryStorage.new(files, config.root)
99
+ end
100
+
74
101
  def text_document_uri(params)
75
102
  params.dig('textDocument', 'uri').sub('file://', '')
76
103
  end
77
104
 
105
+ def relative_path_from_text_document_uri(params)
106
+ @storage.relative_path(text_document_uri(params))
107
+ end
108
+
78
109
  def text_document_text(params)
79
110
  params.dig('textDocument', 'text')
80
111
  end
@@ -83,9 +114,13 @@ module ThemeCheck
83
114
  params.dig('contentChanges', 0, 'text')
84
115
  end
85
116
 
86
- def analyze_and_send_offenses(file_path)
87
- root = ThemeCheck::Config.find(file_path) || @root_path
88
- config = ThemeCheck::Config.from_path(root)
117
+ def config_for_path(path)
118
+ root = ThemeCheck::Config.find(path) || @root_path
119
+ ThemeCheck::Config.from_path(root)
120
+ end
121
+
122
+ def analyze_and_send_offenses(absolute_path)
123
+ config = config_for_path(absolute_path)
89
124
  storage = ThemeCheck::FileSystemStorage.new(
90
125
  config.root,
91
126
  ignored_patterns: config.ignored_patterns
@@ -104,8 +139,12 @@ module ThemeCheck
104
139
  analyzer.offenses
105
140
  end
106
141
 
107
- def completions(uri, line, col)
108
- @completion_engine.completions(uri, line, col)
142
+ def completions(relative_path, line, col)
143
+ @completion_engine.completions(relative_path, line, col)
144
+ end
145
+
146
+ def document_links(relative_path)
147
+ @document_link_engine.document_links(relative_path)
109
148
  end
110
149
 
111
150
  def send_diagnostics(offenses)
@@ -131,7 +170,7 @@ module ThemeCheck
131
170
  send_response(
132
171
  method: 'textDocument/publishDiagnostics',
133
172
  params: {
134
- uri: "file:#{path}",
173
+ uri: "file://#{path}",
135
174
  diagnostics: offenses.map { |offense| offense_to_diagnostic(offense) },
136
175
  },
137
176
  )
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
  require 'json'
3
3
  require 'stringio'
4
- require 'active_support/core_ext/string/inflections'
5
4
 
6
5
  module ThemeCheck
7
6
  module LanguageServer
8
7
  class DoneStreaming < StandardError; end
8
+
9
9
  class IncompatibleStream < StandardError; end
10
10
 
11
11
  class Server
@@ -15,7 +15,7 @@ module ThemeCheck
15
15
  def initialize(
16
16
  in_stream: STDIN,
17
17
  out_stream: STDOUT,
18
- err_stream: $DEBUG ? File.open('/tmp/lsp.log', 'a') : STDERR,
18
+ err_stream: STDERR,
19
19
  should_raise_errors: false
20
20
  )
21
21
  validate!([in_stream, out_stream, err_stream])
@@ -50,7 +50,7 @@ module ThemeCheck
50
50
 
51
51
  def send_response(response)
52
52
  response_body = JSON.dump(response)
53
- log(response_body) if $DEBUG
53
+ log(JSON.pretty_generate(response)) if $DEBUG
54
54
 
55
55
  @out.write("Content-Length: #{response_body.size}\r\n")
56
56
  @out.write("\r\n")
@@ -98,7 +98,7 @@ module ThemeCheck
98
98
  end
99
99
 
100
100
  def to_snake_case(method_name)
101
- method_name.gsub(/[^\w]/, '_').underscore
101
+ StringHelpers.underscore(method_name.gsub(/[^\w]/, '_'))
102
102
  end
103
103
 
104
104
  def initial_line
@@ -6,6 +6,17 @@ module ThemeCheck
6
6
  extend ChecksTracking
7
7
  include ParsingHelpers
8
8
 
9
+ TAG = /#{Liquid::TagStart}.*?#{Liquid::TagEnd}/om
10
+ VARIABLE = /#{Liquid::VariableStart}.*?#{Liquid::VariableEnd}/om
11
+ START_OR_END_QUOTE = /(^['"])|(['"]$)/
12
+ QUOTED_LIQUID_ATTRIBUTE = %r{
13
+ '(?:#{TAG}|#{VARIABLE}|[^'])*'| # any combination of tag/variable or non straight quote inside straight quotes
14
+ "(?:#{TAG}|#{VARIABLE}|[^"])*" # any combination of tag/variable or non double quotes inside double quotes
15
+ }omix
16
+ ATTR = /[a-z0-9-]+/i
17
+ HTML_ATTRIBUTE = /#{ATTR}(?:=#{QUOTED_LIQUID_ATTRIBUTE})?/omix
18
+ HTML_ATTRIBUTES = /(?:#{HTML_ATTRIBUTE}|\s)*/omix
19
+
9
20
  def add_offense(message, node: nil, template: node&.template, markup: nil, line_number: nil, &block)
10
21
  offenses << Offense.new(check: self, message: message, template: template, node: node, markup: markup, line_number: line_number, correction: block)
11
22
  end
@@ -1,5 +1,4 @@
1
1
  # frozen_string_literal: true
2
- require 'active_support/core_ext/string/inflections'
3
2
 
4
3
  module ThemeCheck
5
4
  # A node from the Liquid AST, the result of parsing a template.
@@ -101,7 +100,7 @@ module ThemeCheck
101
100
  # The `:under_score_name` of this type of node. Used to dispatch to the `on_<type_name>`
102
101
  # and `after_<type_name>` check methods.
103
102
  def type_name
104
- @type_name ||= @value.class.name.demodulize.underscore.to_sym
103
+ @type_name ||= StringHelpers.underscore(StringHelpers.demodulize(@value.class.name)).to_sym
105
104
  end
106
105
 
107
106
  # Is this node inside a `{% liquid ... %}` block?
@@ -32,6 +32,8 @@ module ThemeCheck
32
32
  node&.markup
33
33
  end
34
34
 
35
+ raise ArgumentError, "Offense markup cannot be an empty string" if @markup.is_a?(String) && @markup.empty?
36
+
35
37
  @line_number = if line_number
36
38
  line_number
37
39
  elsif @node
@@ -83,7 +85,7 @@ module ThemeCheck
83
85
  end
84
86
 
85
87
  def check_name
86
- check.class.name.demodulize
88
+ StringHelpers.demodulize(check.class.name)
87
89
  end
88
90
 
89
91
  def doc
@@ -24,7 +24,7 @@ module ThemeCheck
24
24
  puts "Grabbing sha256 checksum from Rubygems.org"
25
25
  require 'digest/sha2'
26
26
  require 'open-uri'
27
- gem_checksum = open("https://rubygems.org/downloads/theme-check-#{ThemeCheck::VERSION}.gem") do |io|
27
+ gem_checksum = URI.open("https://rubygems.org/downloads/theme-check-#{ThemeCheck::VERSION}.gem") do |io|
28
28
  Digest::SHA256.new.hexdigest(io.read)
29
29
  end
30
30
 
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+ require 'theme_check/version'
3
+
4
+ module ThemeCheck
5
+ class Releaser
6
+ ROOT = File.expand_path('../../..', __FILE__)
7
+ LIB = File.join(ROOT, 'lib')
8
+
9
+ class VersionError < StandardError; end
10
+
11
+ def release(version)
12
+ raise VersionError, "Missing version argument." if version.nil?
13
+ raise VersionError, "Version should be a string." unless version.is_a?(String)
14
+ raise VersionError, "Version should be a valid semver version." unless version =~ /^\d+\.\d+.\d+$/
15
+ update_docs(version)
16
+ update_version(version)
17
+ end
18
+
19
+ def update_version(version)
20
+ version_file_path = File.join(LIB, 'theme_check/version.rb')
21
+ version_file = File.read(version_file_path)
22
+ updated_version_file = version_file.gsub(ThemeCheck::VERSION, version)
23
+
24
+ return if updated_version_file == version_file
25
+ puts "Updating version to #{version} in #{version_file_path}."
26
+ File.write(version_file_path, updated_version_file)
27
+ end
28
+
29
+ def update_docs(version)
30
+ Dir[ROOT + '/docs/checks/*.md'].each do |filename|
31
+ doc_content = File.read(filename)
32
+ updated_doc_content = doc_content.gsub('THEME_CHECK_VERSION', version)
33
+ next if updated_doc_content == doc_content
34
+ puts "Replacing `THEME_CHECK_VERSION` with #{version} in #{Pathname.new(filename).relative_path_from(ROOT)}"
35
+ File.write(filename, updated_doc_content)
36
+ end
37
+ end
38
+ end
39
+ end