theme-check 0.3.1 → 0.6.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +13 -3
  3. data/CHANGELOG.md +61 -0
  4. data/CONTRIBUTING.md +5 -2
  5. data/Gemfile +5 -3
  6. data/README.md +11 -4
  7. data/RELEASING.md +2 -2
  8. data/config/default.yml +14 -0
  9. data/data/shopify_liquid/tags.yml +27 -0
  10. data/data/shopify_translation_keys.yml +850 -0
  11. data/docs/checks/CHECK_DOCS_TEMPLATE.md +47 -0
  12. data/docs/checks/asset_size_css.md +52 -0
  13. data/docs/checks/asset_size_javascript.md +79 -0
  14. data/docs/checks/convert_include_to_render.md +48 -0
  15. data/docs/checks/default_locale.md +46 -0
  16. data/docs/checks/deprecated_filter.md +46 -0
  17. data/docs/checks/img_width_and_height.md +79 -0
  18. data/docs/checks/liquid_tag.md +65 -0
  19. data/docs/checks/matching_schema_translations.md +93 -0
  20. data/docs/checks/matching_translations.md +72 -0
  21. data/docs/checks/missing_enable_comment.md +50 -0
  22. data/docs/checks/missing_required_template_files.md +26 -0
  23. data/docs/checks/missing_template.md +40 -0
  24. data/docs/checks/nested_snippet.md +69 -0
  25. data/docs/checks/parser_blocking_javascript.md +97 -0
  26. data/docs/checks/required_directories.md +25 -0
  27. data/docs/checks/required_layout_theme_object.md +28 -0
  28. data/docs/checks/space_inside_braces.md +63 -0
  29. data/docs/checks/syntax_error.md +49 -0
  30. data/docs/checks/template_length.md +50 -0
  31. data/docs/checks/translation_key_exists.md +63 -0
  32. data/docs/checks/undefined_object.md +53 -0
  33. data/docs/checks/unknown_filter.md +45 -0
  34. data/docs/checks/unused_assign.md +47 -0
  35. data/docs/checks/unused_snippet.md +32 -0
  36. data/docs/checks/valid_html_translation.md +53 -0
  37. data/docs/checks/valid_json.md +60 -0
  38. data/docs/checks/valid_schema.md +50 -0
  39. data/lib/theme_check.rb +3 -0
  40. data/lib/theme_check/asset_file.rb +34 -0
  41. data/lib/theme_check/check.rb +19 -9
  42. data/lib/theme_check/checks/asset_size_css.rb +89 -0
  43. data/lib/theme_check/checks/asset_size_javascript.rb +68 -0
  44. data/lib/theme_check/checks/convert_include_to_render.rb +1 -1
  45. data/lib/theme_check/checks/default_locale.rb +1 -0
  46. data/lib/theme_check/checks/deprecated_filter.rb +1 -1
  47. data/lib/theme_check/checks/img_width_and_height.rb +74 -0
  48. data/lib/theme_check/checks/liquid_tag.rb +3 -3
  49. data/lib/theme_check/checks/matching_schema_translations.rb +1 -0
  50. data/lib/theme_check/checks/matching_translations.rb +1 -0
  51. data/lib/theme_check/checks/missing_enable_comment.rb +1 -0
  52. data/lib/theme_check/checks/missing_required_template_files.rb +1 -2
  53. data/lib/theme_check/checks/missing_template.rb +1 -0
  54. data/lib/theme_check/checks/nested_snippet.rb +1 -0
  55. data/lib/theme_check/checks/parser_blocking_javascript.rb +7 -14
  56. data/lib/theme_check/checks/required_directories.rb +1 -1
  57. data/lib/theme_check/checks/required_layout_theme_object.rb +1 -1
  58. data/lib/theme_check/checks/space_inside_braces.rb +1 -0
  59. data/lib/theme_check/checks/syntax_error.rb +1 -0
  60. data/lib/theme_check/checks/template_length.rb +1 -0
  61. data/lib/theme_check/checks/translation_key_exists.rb +17 -1
  62. data/lib/theme_check/checks/undefined_object.rb +29 -10
  63. data/lib/theme_check/checks/unknown_filter.rb +1 -0
  64. data/lib/theme_check/checks/unused_assign.rb +5 -3
  65. data/lib/theme_check/checks/unused_snippet.rb +1 -0
  66. data/lib/theme_check/checks/valid_html_translation.rb +1 -0
  67. data/lib/theme_check/checks/valid_json.rb +1 -0
  68. data/lib/theme_check/checks/valid_schema.rb +1 -0
  69. data/lib/theme_check/cli.rb +39 -12
  70. data/lib/theme_check/config.rb +5 -2
  71. data/lib/theme_check/in_memory_storage.rb +11 -3
  72. data/lib/theme_check/language_server.rb +12 -0
  73. data/lib/theme_check/language_server/completion_engine.rb +38 -0
  74. data/lib/theme_check/language_server/completion_helper.rb +25 -0
  75. data/lib/theme_check/language_server/completion_provider.rb +28 -0
  76. data/lib/theme_check/language_server/completion_providers/filter_completion_provider.rb +51 -0
  77. data/lib/theme_check/language_server/completion_providers/object_completion_provider.rb +31 -0
  78. data/lib/theme_check/language_server/completion_providers/render_snippet_completion_provider.rb +43 -0
  79. data/lib/theme_check/language_server/completion_providers/tag_completion_provider.rb +31 -0
  80. data/lib/theme_check/language_server/constants.rb +10 -0
  81. data/lib/theme_check/language_server/document_link_engine.rb +47 -0
  82. data/lib/theme_check/language_server/handler.rb +93 -6
  83. data/lib/theme_check/language_server/position_helper.rb +27 -0
  84. data/lib/theme_check/language_server/protocol.rb +41 -0
  85. data/lib/theme_check/language_server/server.rb +8 -2
  86. data/lib/theme_check/language_server/tokens.rb +55 -0
  87. data/lib/theme_check/liquid_check.rb +11 -0
  88. data/lib/theme_check/offense.rb +51 -14
  89. data/lib/theme_check/regex_helpers.rb +15 -0
  90. data/lib/theme_check/remote_asset_file.rb +44 -0
  91. data/lib/theme_check/shopify_liquid.rb +1 -0
  92. data/lib/theme_check/shopify_liquid/deprecated_filter.rb +4 -0
  93. data/lib/theme_check/shopify_liquid/tag.rb +16 -0
  94. data/lib/theme_check/theme.rb +7 -1
  95. data/lib/theme_check/version.rb +1 -1
  96. metadata +52 -2
@@ -5,6 +5,7 @@ module ThemeCheck
5
5
  class UnusedSnippet < LiquidCheck
6
6
  severity :suggestion
7
7
  category :liquid
8
+ doc docs_url(__FILE__)
8
9
 
9
10
  def initialize
10
11
  @used_templates = Set.new
@@ -5,6 +5,7 @@ require 'nokogumbo'
5
5
  module ThemeCheck
6
6
  class ValidHTMLTranslation < JsonCheck
7
7
  severity :suggestion
8
+ doc docs_url(__FILE__)
8
9
 
9
10
  def on_file(file)
10
11
  return unless file.name.starts_with?("locales/")
@@ -3,6 +3,7 @@ module ThemeCheck
3
3
  class ValidJson < JsonCheck
4
4
  severity :error
5
5
  category :json
6
+ doc docs_url(__FILE__)
6
7
 
7
8
  def on_file(file)
8
9
  if file.parse_error
@@ -3,6 +3,7 @@ module ThemeCheck
3
3
  class ValidSchema < LiquidCheck
4
4
  severity :suggestion
5
5
  category :json
6
+ doc docs_url(__FILE__)
6
7
 
7
8
  def on_schema(node)
8
9
  JSON.parse(node.value.nodelist.join)
@@ -7,12 +7,14 @@ module ThemeCheck
7
7
  Usage: theme-check [options] /path/to/your/theme
8
8
 
9
9
  Options:
10
- -c, [--category] # Only run this category of checks
11
- -x, [--exclude-category] # Exclude this category of checks
12
- -l, [--list] # List enabled checks
13
- -a, [--auto-correct] # Automatically fix offenses
14
- -h, [--help] # Show this. Hi!
15
- -v, [--version] # Print Theme Check version
10
+ --init Generate a .theme-check.yml file in the current directory
11
+ -C, --config <path> Use the config provided, overriding .theme-check.yml if present
12
+ -c, --category <category> Only run this category of checks
13
+ -x, --exclude-category <category> Exclude this category of checks
14
+ -l, --list List enabled checks
15
+ -a, --auto-correct Automatically fix offenses
16
+ -h, --help Show this. Hi!
17
+ -v, --version Print Theme Check version
16
18
 
17
19
  Description:
18
20
  Theme Check helps you follow Shopify Themes & Liquid best practices by analyzing the
@@ -22,12 +24,13 @@ module ThemeCheck
22
24
  END
23
25
 
24
26
  def run(argv)
25
- path = "."
27
+ @path = "."
26
28
 
27
29
  command = :check
28
30
  only_categories = []
29
31
  exclude_categories = []
30
32
  auto_correct = false
33
+ config_path = nil
31
34
 
32
35
  args = argv.dup
33
36
  while (arg = args.shift)
@@ -36,6 +39,8 @@ module ThemeCheck
36
39
  raise Abort, USAGE
37
40
  when "--version", "-v"
38
41
  command = :version
42
+ when "--config", "-C"
43
+ config_path = Pathname.new(args.shift)
39
44
  when "--category", "-c"
40
45
  only_categories << args.shift.to_sym
41
46
  when "--exclude-category", "-x"
@@ -44,15 +49,26 @@ module ThemeCheck
44
49
  command = :list
45
50
  when "--auto-correct", "-a"
46
51
  auto_correct = true
52
+ when "--init"
53
+ command = :init
47
54
  else
48
- path = arg
55
+ @path = arg
49
56
  end
50
57
  end
51
58
 
52
- @config = ThemeCheck::Config.from_path(path)
53
- @config.only_categories = only_categories
54
- @config.exclude_categories = exclude_categories
55
- @config.auto_correct = auto_correct
59
+ unless [:version, :init].include?(command)
60
+ @config = if config_path.present?
61
+ ThemeCheck::Config.new(
62
+ root: @path,
63
+ configuration: ThemeCheck::Config.load_file(config_path)
64
+ )
65
+ else
66
+ ThemeCheck::Config.from_path(@path)
67
+ end
68
+ @config.only_categories = only_categories
69
+ @config.exclude_categories = exclude_categories
70
+ @config.auto_correct = auto_correct
71
+ end
56
72
 
57
73
  send(command)
58
74
  end
@@ -75,6 +91,17 @@ module ThemeCheck
75
91
  puts ThemeCheck::VERSION
76
92
  end
77
93
 
94
+ def init
95
+ dotfile_path = ThemeCheck::Config.find(@path)
96
+ if dotfile_path.nil?
97
+ File.write(File.join(@path, ThemeCheck::Config::DOTFILE), File.read(ThemeCheck::Config::DEFAULT_CONFIG))
98
+
99
+ puts "Writing new #{ThemeCheck::Config::DOTFILE} to #{@path}"
100
+ else
101
+ raise Abort, "#{ThemeCheck::Config::DOTFILE} already exists at #{@path}."
102
+ end
103
+ end
104
+
78
105
  def check
79
106
  puts "Checking #{@config.root} ..."
80
107
  storage = ThemeCheck::FileSystemStorage.new(@config.root, ignored_patterns: @config.ignored_patterns)
@@ -10,6 +10,8 @@ module ThemeCheck
10
10
  attr_accessor :only_categories, :exclude_categories, :auto_correct
11
11
 
12
12
  class << self
13
+ attr_reader :last_loaded_config
14
+
13
15
  def from_path(path)
14
16
  if (filename = find(path))
15
17
  new(root: filename.dirname, configuration: load_file(filename))
@@ -36,6 +38,7 @@ module ThemeCheck
36
38
  end
37
39
 
38
40
  def load_file(absolute_path)
41
+ @last_loaded_config = absolute_path
39
42
  YAML.load_file(absolute_path)
40
43
  end
41
44
 
@@ -83,8 +86,8 @@ module ThemeCheck
83
86
 
84
87
  check_class = ThemeCheck.const_get(check_name)
85
88
 
86
- next if exclude_categories.include?(check_class.category)
87
- next if only_categories.any? && !only_categories.include?(check_class.category)
89
+ next if check_class.categories.any? { |category| exclude_categories.include?(category) }
90
+ next if only_categories.any? && check_class.categories.none? { |category| only_categories.include?(category) }
88
91
 
89
92
  options_for_check = options.transform_keys(&:to_sym)
90
93
  options_for_check.delete(:enabled)
@@ -6,20 +6,28 @@
6
6
  # as a big hash already, leave it like that and save yourself some IO.
7
7
  module ThemeCheck
8
8
  class InMemoryStorage < Storage
9
- def initialize(files)
9
+ def initialize(files = {}, root = nil)
10
10
  @files = files
11
+ @root = root
11
12
  end
12
13
 
13
14
  def path(name)
15
+ return File.join(@root, name) unless @root.nil?
16
+ name
17
+ end
18
+
19
+ def relative_path(name)
20
+ path = Pathname.new(name)
21
+ return path.relative_path_from(@root).to_s unless path.relative? || @root.nil?
14
22
  name
15
23
  end
16
24
 
17
25
  def read(name)
18
- @files[name]
26
+ @files[relative_path(name)]
19
27
  end
20
28
 
21
29
  def write(name, content)
22
- @files[name] = content
30
+ @files[relative_path(name)] = content
23
31
  end
24
32
 
25
33
  def files
@@ -1,6 +1,18 @@
1
1
  # frozen_string_literal: true
2
+ require_relative "language_server/protocol"
3
+ require_relative "language_server/constants"
2
4
  require_relative "language_server/handler"
3
5
  require_relative "language_server/server"
6
+ require_relative "language_server/tokens"
7
+ require_relative "language_server/position_helper"
8
+ require_relative "language_server/completion_helper"
9
+ require_relative "language_server/completion_provider"
10
+ require_relative "language_server/completion_engine"
11
+ require_relative "language_server/document_link_engine"
12
+
13
+ Dir[__dir__ + "/language_server/completion_providers/*.rb"].each do |file|
14
+ require file
15
+ end
4
16
 
5
17
  module ThemeCheck
6
18
  module LanguageServer
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class CompletionEngine
6
+ include PositionHelper
7
+
8
+ def initialize(storage)
9
+ @storage = storage
10
+ @providers = CompletionProvider.all.map { |x| x.new(storage) }
11
+ end
12
+
13
+ def completions(name, line, col)
14
+ buffer = @storage.read(name)
15
+ cursor = from_line_column_to_index(buffer, line, col)
16
+ token = find_token(buffer, cursor)
17
+ return [] if token.nil?
18
+
19
+ @providers.flat_map do |p|
20
+ p.completions(
21
+ token.content,
22
+ cursor - token.start
23
+ )
24
+ end
25
+ end
26
+
27
+ def find_token(buffer, cursor)
28
+ Tokens.new(buffer).find do |token|
29
+ # Here we include the next character and exclude the first
30
+ # one becase when we want to autocomplete inside a token
31
+ # and at most 1 outside it since the cursor could be placed
32
+ # at the end of the token.
33
+ token.start < cursor && cursor <= token.end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ module CompletionHelper
6
+ WORD = /\w+/
7
+
8
+ def cursor_on_start_content?(content, cursor, regex)
9
+ content.slice(0, cursor).match?(/#{regex}(?:\s|\n)*$/m)
10
+ end
11
+
12
+ def cursor_on_first_word?(content, cursor)
13
+ word = content.match(WORD)
14
+ return false if word.nil?
15
+ word_start = word.begin(0)
16
+ word_end = word.end(0)
17
+ word_start <= cursor && cursor <= word_end
18
+ end
19
+
20
+ def first_word(content)
21
+ return content.match(WORD)[0] if content.match?(WORD)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class CompletionProvider
6
+ include CompletionHelper
7
+ include RegexHelpers
8
+
9
+ class << self
10
+ def all
11
+ @all ||= []
12
+ end
13
+
14
+ def inherited(subclass)
15
+ all << subclass
16
+ end
17
+ end
18
+
19
+ def initialize(storage = InMemoryStorage.new)
20
+ @storage = storage
21
+ end
22
+
23
+ def completions(content, cursor)
24
+ raise NotImplementedError
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class FilterCompletionProvider < CompletionProvider
6
+ NAMED_FILTER = /#{Liquid::FilterSeparator}\s*(\w+)/o
7
+
8
+ def completions(content, cursor)
9
+ return [] unless can_complete?(content, cursor)
10
+ available_labels
11
+ .select { |w| w.starts_with?(partial(content, cursor)) }
12
+ .map { |filter| filter_to_completion(filter) }
13
+ end
14
+
15
+ def can_complete?(content, cursor)
16
+ content.match?(Liquid::FilterSeparator) && (
17
+ cursor_on_start_content?(content, cursor, Liquid::FilterSeparator) ||
18
+ cursor_on_filter?(content, cursor)
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ def available_labels
25
+ @labels ||= ShopifyLiquid::Filter.labels - ShopifyLiquid::DeprecatedFilter.labels
26
+ end
27
+
28
+ def cursor_on_filter?(content, cursor)
29
+ return false unless content.match?(NAMED_FILTER)
30
+ matches(content, NAMED_FILTER).any? do |match|
31
+ match.begin(1) <= cursor && cursor < match.end(1) + 1 # including next character
32
+ end
33
+ end
34
+
35
+ def partial(content, cursor)
36
+ return '' unless content.match?(NAMED_FILTER)
37
+ partial_match = matches(content, NAMED_FILTER).find do |match|
38
+ match.begin(1) <= cursor && cursor < match.end(1) + 1 # including next character
39
+ end
40
+ partial_match[1]
41
+ end
42
+
43
+ def filter_to_completion(filter)
44
+ {
45
+ label: filter,
46
+ kind: CompletionItemKinds::FUNCTION,
47
+ }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class ObjectCompletionProvider < CompletionProvider
6
+ def completions(content, cursor)
7
+ return [] unless can_complete?(content, cursor)
8
+ partial = first_word(content) || ''
9
+ ShopifyLiquid::Object.labels
10
+ .select { |w| w.starts_with?(partial) }
11
+ .map { |object| object_to_completion(object) }
12
+ end
13
+
14
+ def can_complete?(content, cursor)
15
+ content.match?(Liquid::VariableStart) && (
16
+ cursor_on_first_word?(content, cursor) ||
17
+ cursor_on_start_content?(content, cursor, Liquid::VariableStart)
18
+ )
19
+ end
20
+
21
+ private
22
+
23
+ def object_to_completion(object)
24
+ {
25
+ label: object,
26
+ kind: CompletionItemKinds::VARIABLE,
27
+ }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -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.starts_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
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class TagCompletionProvider < CompletionProvider
6
+ def completions(content, cursor)
7
+ return [] unless can_complete?(content, cursor)
8
+ partial = first_word(content) || ''
9
+ ShopifyLiquid::Tag.labels
10
+ .select { |w| w.starts_with?(partial) }
11
+ .map { |tag| tag_to_completion(tag) }
12
+ end
13
+
14
+ def can_complete?(content, cursor)
15
+ content.starts_with?(Liquid::TagStart) && (
16
+ cursor_on_first_word?(content, cursor) ||
17
+ cursor_on_start_content?(content, cursor, Liquid::TagStart)
18
+ )
19
+ end
20
+
21
+ private
22
+
23
+ def tag_to_completion(tag)
24
+ {
25
+ label: tag,
26
+ kind: CompletionItemKinds::KEYWORD,
27
+ }
28
+ end
29
+ end
30
+ end
31
+ end