theme-check 0.3.2 → 0.7.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +10 -3
  3. data/.rubocop.yml +12 -3
  4. data/CHANGELOG.md +42 -0
  5. data/CONTRIBUTING.md +5 -2
  6. data/Gemfile +5 -3
  7. data/LICENSE.md +2 -0
  8. data/README.md +12 -4
  9. data/RELEASING.md +10 -3
  10. data/Rakefile +6 -0
  11. data/config/default.yml +16 -0
  12. data/data/shopify_liquid/tags.yml +27 -0
  13. data/data/shopify_translation_keys.yml +850 -0
  14. data/docs/checks/CHECK_DOCS_TEMPLATE.md +47 -0
  15. data/docs/checks/asset_size_css.md +52 -0
  16. data/docs/checks/asset_size_javascript.md +79 -0
  17. data/docs/checks/convert_include_to_render.md +48 -0
  18. data/docs/checks/default_locale.md +46 -0
  19. data/docs/checks/deprecated_filter.md +46 -0
  20. data/docs/checks/img_width_and_height.md +79 -0
  21. data/docs/checks/liquid_tag.md +65 -0
  22. data/docs/checks/matching_schema_translations.md +93 -0
  23. data/docs/checks/matching_translations.md +72 -0
  24. data/docs/checks/missing_enable_comment.md +50 -0
  25. data/docs/checks/missing_required_template_files.md +26 -0
  26. data/docs/checks/missing_template.md +40 -0
  27. data/docs/checks/nested_snippet.md +69 -0
  28. data/docs/checks/parser_blocking_javascript.md +97 -0
  29. data/docs/checks/remote_asset.md +82 -0
  30. data/docs/checks/required_directories.md +25 -0
  31. data/docs/checks/required_layout_theme_object.md +28 -0
  32. data/docs/checks/space_inside_braces.md +63 -0
  33. data/docs/checks/syntax_error.md +49 -0
  34. data/docs/checks/template_length.md +50 -0
  35. data/docs/checks/translation_key_exists.md +63 -0
  36. data/docs/checks/undefined_object.md +53 -0
  37. data/docs/checks/unknown_filter.md +45 -0
  38. data/docs/checks/unused_assign.md +47 -0
  39. data/docs/checks/unused_snippet.md +32 -0
  40. data/docs/checks/valid_html_translation.md +53 -0
  41. data/docs/checks/valid_json.md +60 -0
  42. data/docs/checks/valid_schema.md +50 -0
  43. data/lib/theme_check.rb +4 -0
  44. data/lib/theme_check/asset_file.rb +34 -0
  45. data/lib/theme_check/check.rb +20 -10
  46. data/lib/theme_check/checks/asset_size_css.rb +89 -0
  47. data/lib/theme_check/checks/asset_size_javascript.rb +68 -0
  48. data/lib/theme_check/checks/convert_include_to_render.rb +1 -1
  49. data/lib/theme_check/checks/default_locale.rb +1 -0
  50. data/lib/theme_check/checks/deprecated_filter.rb +1 -1
  51. data/lib/theme_check/checks/img_width_and_height.rb +74 -0
  52. data/lib/theme_check/checks/liquid_tag.rb +3 -3
  53. data/lib/theme_check/checks/matching_schema_translations.rb +1 -0
  54. data/lib/theme_check/checks/matching_translations.rb +2 -1
  55. data/lib/theme_check/checks/missing_enable_comment.rb +1 -0
  56. data/lib/theme_check/checks/missing_required_template_files.rb +1 -2
  57. data/lib/theme_check/checks/missing_template.rb +1 -0
  58. data/lib/theme_check/checks/nested_snippet.rb +1 -0
  59. data/lib/theme_check/checks/parser_blocking_javascript.rb +8 -15
  60. data/lib/theme_check/checks/remote_asset.rb +98 -0
  61. data/lib/theme_check/checks/required_directories.rb +1 -1
  62. data/lib/theme_check/checks/required_layout_theme_object.rb +1 -1
  63. data/lib/theme_check/checks/space_inside_braces.rb +1 -0
  64. data/lib/theme_check/checks/syntax_error.rb +1 -0
  65. data/lib/theme_check/checks/template_length.rb +1 -0
  66. data/lib/theme_check/checks/translation_key_exists.rb +14 -1
  67. data/lib/theme_check/checks/undefined_object.rb +16 -7
  68. data/lib/theme_check/checks/unknown_filter.rb +1 -0
  69. data/lib/theme_check/checks/unused_assign.rb +5 -3
  70. data/lib/theme_check/checks/unused_snippet.rb +1 -0
  71. data/lib/theme_check/checks/valid_html_translation.rb +2 -1
  72. data/lib/theme_check/checks/valid_json.rb +1 -0
  73. data/lib/theme_check/checks/valid_schema.rb +1 -0
  74. data/lib/theme_check/cli.rb +49 -13
  75. data/lib/theme_check/config.rb +5 -2
  76. data/lib/theme_check/disabled_checks.rb +2 -2
  77. data/lib/theme_check/in_memory_storage.rb +13 -8
  78. data/lib/theme_check/language_server.rb +12 -0
  79. data/lib/theme_check/language_server/completion_engine.rb +38 -0
  80. data/lib/theme_check/language_server/completion_helper.rb +25 -0
  81. data/lib/theme_check/language_server/completion_provider.rb +28 -0
  82. data/lib/theme_check/language_server/completion_providers/filter_completion_provider.rb +51 -0
  83. data/lib/theme_check/language_server/completion_providers/object_completion_provider.rb +31 -0
  84. data/lib/theme_check/language_server/completion_providers/render_snippet_completion_provider.rb +43 -0
  85. data/lib/theme_check/language_server/completion_providers/tag_completion_provider.rb +31 -0
  86. data/lib/theme_check/language_server/constants.rb +10 -0
  87. data/lib/theme_check/language_server/document_link_engine.rb +48 -0
  88. data/lib/theme_check/language_server/handler.rb +105 -10
  89. data/lib/theme_check/language_server/position_helper.rb +27 -0
  90. data/lib/theme_check/language_server/protocol.rb +41 -0
  91. data/lib/theme_check/language_server/server.rb +9 -4
  92. data/lib/theme_check/language_server/tokens.rb +55 -0
  93. data/lib/theme_check/liquid_check.rb +11 -0
  94. data/lib/theme_check/node.rb +1 -2
  95. data/lib/theme_check/offense.rb +52 -15
  96. data/lib/theme_check/regex_helpers.rb +15 -0
  97. data/lib/theme_check/releaser.rb +39 -0
  98. data/lib/theme_check/remote_asset_file.rb +44 -0
  99. data/lib/theme_check/shopify_liquid.rb +1 -0
  100. data/lib/theme_check/shopify_liquid/deprecated_filter.rb +10 -8
  101. data/lib/theme_check/shopify_liquid/filter.rb +3 -5
  102. data/lib/theme_check/shopify_liquid/object.rb +2 -6
  103. data/lib/theme_check/shopify_liquid/tag.rb +14 -0
  104. data/lib/theme_check/storage.rb +3 -3
  105. data/lib/theme_check/string_helpers.rb +47 -0
  106. data/lib/theme_check/tags.rb +1 -2
  107. data/lib/theme_check/theme.rb +7 -1
  108. data/lib/theme_check/version.rb +1 -1
  109. data/theme-check.gemspec +1 -2
  110. metadata +57 -18
@@ -1,8 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
  module ThemeCheck
3
+ module SystemTranslations
4
+ extend self
5
+
6
+ def translations
7
+ @translations ||= YAML.load(File.read("#{__dir__}/../../../data/shopify_translation_keys.yml")).to_set
8
+ end
9
+
10
+ def include?(key)
11
+ translations.include?(key)
12
+ end
13
+ end
14
+
3
15
  class TranslationKeyExists < LiquidCheck
4
16
  severity :error
5
17
  category :translation
18
+ doc docs_url(__FILE__)
6
19
 
7
20
  def on_variable(node)
8
21
  return unless @theme.default_locale_json&.content&.is_a?(Hash)
@@ -11,7 +24,7 @@ module ThemeCheck
11
24
  return unless (key_node = node.children.first)
12
25
  return unless key_node.value.is_a?(String)
13
26
 
14
- unless key_exists?(key_node.value)
27
+ unless key_exists?(key_node.value) || SystemTranslations.include?(key_node.value)
15
28
  add_offense(
16
29
  "'#{key_node.value}' does not have a matching entry in '#{@theme.default_locale_json.relative_path}'",
17
30
  node: node,
@@ -2,7 +2,7 @@
2
2
  module ThemeCheck
3
3
  class UndefinedObject < LiquidCheck
4
4
  category :liquid
5
- doc "https://shopify.dev/docs/themes/liquid/reference/objects"
5
+ doc docs_url(__FILE__)
6
6
  severity :error
7
7
 
8
8
  class TemplateInfo
@@ -21,7 +21,13 @@ module ThemeCheck
21
21
  end
22
22
 
23
23
  def add_variable_lookup(name:, node:)
24
- line_number = node.parent.line_number
24
+ parent = node
25
+ line_number = nil
26
+ loop do
27
+ line_number = parent.line_number
28
+ parent = parent.parent
29
+ break unless line_number.nil? && parent
30
+ end
25
31
  key = [name, line_number]
26
32
  @all_variable_lookups[key] = node
27
33
  end
@@ -129,12 +135,12 @@ module ThemeCheck
129
135
 
130
136
  def each_template
131
137
  @files.each do |(name, info)|
132
- next if name.starts_with?('snippets/')
138
+ next if name.start_with?('snippets/')
133
139
  yield [name, info]
134
140
  end
135
141
  end
136
142
 
137
- def check_object(info, all_global_objects, render_node = nil)
143
+ def check_object(info, all_global_objects, render_node = nil, visited_snippets = Set.new)
138
144
  check_undefined(info, all_global_objects, render_node)
139
145
 
140
146
  info.each_snippet do |(snippet_name, node)|
@@ -143,7 +149,10 @@ module ThemeCheck
143
149
 
144
150
  snippet_variables = node.value.attributes.keys +
145
151
  Array[node.value.instance_variable_get("@alias_name")]
146
- check_object(snippet_info, all_global_objects + snippet_variables, node)
152
+ unless visited_snippets.include?(snippet_name)
153
+ visited_snippets << snippet_name
154
+ check_object(snippet_info, all_global_objects + snippet_variables, node, visited_snippets)
155
+ end
147
156
  end
148
157
  end
149
158
 
@@ -151,7 +160,7 @@ module ThemeCheck
151
160
  all_variables = info.all_variables
152
161
 
153
162
  info.each_variable_lookup(!!render_node) do |(key, node)|
154
- name, _line_number = key
163
+ name, line_number = key
155
164
  next if all_variables.include?(name)
156
165
  next if all_global_objects.include?(name)
157
166
 
@@ -161,7 +170,7 @@ module ThemeCheck
161
170
  if render_node
162
171
  add_offense("Missing argument `#{name}`", node: render_node)
163
172
  else
164
- add_offense("Undefined object `#{name}`", node: node)
173
+ add_offense("Undefined object `#{name}`", node: node, line_number: line_number)
165
174
  end
166
175
  end
167
176
  end
@@ -12,6 +12,7 @@ module ThemeCheck
12
12
  class UnknownFilter < LiquidCheck
13
13
  severity :error
14
14
  category :liquid
15
+ doc docs_url(__FILE__)
15
16
 
16
17
  def on_variable(node)
17
18
  used_filters = node.value.filters.map { |name, *_rest| name }
@@ -4,14 +4,16 @@ module ThemeCheck
4
4
  class UnusedAssign < LiquidCheck
5
5
  severity :suggestion
6
6
  category :liquid
7
+ doc docs_url(__FILE__)
7
8
 
8
9
  class TemplateInfo < Struct.new(:used_assigns, :assign_nodes, :includes)
9
- def collect_used_assigns(templates)
10
+ def collect_used_assigns(templates, visited = Set.new)
10
11
  collected = used_assigns
11
12
  # Check recursively inside included snippets for use
12
13
  includes.each do |name|
13
- if templates[name]
14
- collected += templates[name].collect_used_assigns(templates)
14
+ if templates[name] && !visited.include?(name)
15
+ visited << name
16
+ collected += templates[name].collect_used_assigns(templates, visited)
15
17
  end
16
18
  end
17
19
  collected
@@ -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,9 +5,10 @@ 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
- return unless file.name.starts_with?("locales/")
11
+ return unless file.name.start_with?("locales/")
11
12
  return unless file.content.is_a?(Hash)
12
13
 
13
14
  visit_nested(file.content)
@@ -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)
@@ -6,13 +6,18 @@ module ThemeCheck
6
6
  USAGE = <<~END
7
7
  Usage: theme-check [options] /path/to/your/theme
8
8
 
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
9
+ Basic Options:
10
+ -C, --config <path> Use the config provided, overriding .theme-check.yml if present
11
+ -c, --category <category> Only run this category of checks
12
+ -x, --exclude-category <category> Exclude this category of checks
13
+ -a, --auto-correct Automatically fix offenses
14
+
15
+ Miscellaneous:
16
+ --init Generate a .theme-check.yml file
17
+ --print-config Output active config to STDOUT
18
+ -h, --help Show this. Hi!
19
+ -l, --list List enabled checks
20
+ -v, --version Print Theme Check version
16
21
 
17
22
  Description:
18
23
  Theme Check helps you follow Shopify Themes & Liquid best practices by analyzing the
@@ -22,12 +27,13 @@ module ThemeCheck
22
27
  END
23
28
 
24
29
  def run(argv)
25
- path = "."
30
+ @path = "."
26
31
 
27
32
  command = :check
28
33
  only_categories = []
29
34
  exclude_categories = []
30
35
  auto_correct = false
36
+ config_path = nil
31
37
 
32
38
  args = argv.dup
33
39
  while (arg = args.shift)
@@ -36,6 +42,8 @@ module ThemeCheck
36
42
  raise Abort, USAGE
37
43
  when "--version", "-v"
38
44
  command = :version
45
+ when "--config", "-C"
46
+ config_path = Pathname.new(args.shift)
39
47
  when "--category", "-c"
40
48
  only_categories << args.shift.to_sym
41
49
  when "--exclude-category", "-x"
@@ -44,15 +52,28 @@ module ThemeCheck
44
52
  command = :list
45
53
  when "--auto-correct", "-a"
46
54
  auto_correct = true
55
+ when "--init"
56
+ command = :init
57
+ when "--print"
58
+ command = :print
47
59
  else
48
- path = arg
60
+ @path = arg
49
61
  end
50
62
  end
51
63
 
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
64
+ unless [:version, :init].include?(command)
65
+ @config = if config_path
66
+ ThemeCheck::Config.new(
67
+ root: @path,
68
+ configuration: ThemeCheck::Config.load_file(config_path)
69
+ )
70
+ else
71
+ ThemeCheck::Config.from_path(@path)
72
+ end
73
+ @config.only_categories = only_categories
74
+ @config.exclude_categories = exclude_categories
75
+ @config.auto_correct = auto_correct
76
+ end
56
77
 
57
78
  send(command)
58
79
  end
@@ -75,6 +96,21 @@ module ThemeCheck
75
96
  puts ThemeCheck::VERSION
76
97
  end
77
98
 
99
+ def init
100
+ dotfile_path = ThemeCheck::Config.find(@path)
101
+ if dotfile_path.nil?
102
+ File.write(File.join(@path, ThemeCheck::Config::DOTFILE), File.read(ThemeCheck::Config::DEFAULT_CONFIG))
103
+
104
+ puts "Writing new #{ThemeCheck::Config::DOTFILE} to #{@path}"
105
+ else
106
+ raise Abort, "#{ThemeCheck::Config::DOTFILE} already exists at #{@path}."
107
+ end
108
+ end
109
+
110
+ def print
111
+ puts YAML.dump(@config.to_h)
112
+ end
113
+
78
114
  def check
79
115
  puts "Checking #{@config.root} ..."
80
116
  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)
@@ -61,11 +61,11 @@ module ThemeCheck
61
61
  end
62
62
 
63
63
  def start_disabling?(text)
64
- text.strip.starts_with?(DISABLE_START)
64
+ text.strip.start_with?(DISABLE_START)
65
65
  end
66
66
 
67
67
  def stop_disabling?(text)
68
- text.strip.starts_with?(DISABLE_END)
68
+ text.strip.start_with?(DISABLE_END)
69
69
  end
70
70
 
71
71
  # Return a list of checks from a theme-check-disable comment
@@ -6,24 +6,25 @@
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 = "/dev/null")
10
10
  @files = files
11
+ @root = Pathname.new(root)
11
12
  end
12
13
 
13
- def path(name)
14
- name
14
+ def path(relative_path)
15
+ @root.join(relative_path)
15
16
  end
16
17
 
17
- def read(name)
18
- @files[name]
18
+ def read(relative_path)
19
+ @files[relative_path]
19
20
  end
20
21
 
21
- def write(name, content)
22
- @files[name] = content
22
+ def write(relative_path, content)
23
+ @files[relative_path] = content
23
24
  end
24
25
 
25
26
  def files
26
- @values ||= @files.keys
27
+ @files.keys
27
28
  end
28
29
 
29
30
  def directories
@@ -33,5 +34,9 @@ module ThemeCheck
33
34
  .map(&:to_s)
34
35
  .uniq
35
36
  end
37
+
38
+ def relative_path(absolute_path)
39
+ Pathname.new(absolute_path).relative_path_from(@root).to_s
40
+ end
36
41
  end
37
42
  end
@@ -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(relative_path, line, col)
14
+ buffer = @storage.read(relative_path)
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