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
@@ -3,6 +3,7 @@ module ThemeCheck
3
3
  class DefaultLocale < JsonCheck
4
4
  severity :suggestion
5
5
  category :translation
6
+ doc docs_url(__FILE__)
6
7
 
7
8
  def on_end
8
9
  return if @theme.default_locale_json
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  module ThemeCheck
3
3
  class DeprecatedFilter < LiquidCheck
4
- doc "https://shopify.dev/docs/themes/liquid/reference/filters/deprecated-filters"
4
+ doc docs_url(__FILE__)
5
5
  category :liquid
6
6
  severity :suggestion
7
7
 
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ # Reports errors when trying to use parser-blocking script tags
4
+ class ImgWidthAndHeight < LiquidCheck
5
+ include RegexHelpers
6
+ severity :error
7
+ categories :liquid, :performance
8
+ doc docs_url(__FILE__)
9
+
10
+ # Not implemented with lookbehinds and lookaheads because performance was shit!
11
+ IMG_TAG = /<img#{HTML_ATTRIBUTES}>/oxim
12
+ SRC_ATTRIBUTE = /\s(src)=(#{QUOTED_LIQUID_ATTRIBUTE})/oxim
13
+ WIDTH_ATTRIBUTE = /\s(width)=(#{QUOTED_LIQUID_ATTRIBUTE})/oxim
14
+ HEIGHT_ATTRIBUTE = /\s(height)=(#{QUOTED_LIQUID_ATTRIBUTE})/oxim
15
+
16
+ FIELDS = [WIDTH_ATTRIBUTE, HEIGHT_ATTRIBUTE]
17
+ ENDS_IN_CSS_UNIT = /(cm|mm|in|px|pt|pc|em|ex|ch|rem|vw|vh|vmin|vmax|%)$/i
18
+
19
+ def on_document(node)
20
+ @source = node.template.source
21
+ @node = node
22
+ record_offenses
23
+ end
24
+
25
+ private
26
+
27
+ def record_offenses
28
+ matches(@source, IMG_TAG).each do |img_match|
29
+ next unless img_match[0] =~ SRC_ATTRIBUTE
30
+ record_missing_field_offenses(img_match)
31
+ record_units_in_field_offenses(img_match)
32
+ end
33
+ end
34
+
35
+ def record_missing_field_offenses(img_match)
36
+ width = WIDTH_ATTRIBUTE.match(img_match[0])
37
+ height = HEIGHT_ATTRIBUTE.match(img_match[0])
38
+ return if width.present? && height.present?
39
+ missing_width = width.nil?
40
+ missing_height = height.nil?
41
+ error_message = if missing_width && missing_height
42
+ "Missing width and height attributes"
43
+ elsif missing_width
44
+ "Missing width attribute"
45
+ elsif missing_height
46
+ "Missing height attribute"
47
+ end
48
+
49
+ add_offense(
50
+ error_message,
51
+ node: @node,
52
+ markup: img_match[0],
53
+ line_number: @source[0...img_match.begin(0)].count("\n") + 1
54
+ )
55
+ end
56
+
57
+ def record_units_in_field_offenses(img_match)
58
+ FIELDS.each do |field|
59
+ field_match = field.match(img_match[0])
60
+ next if field_match.nil?
61
+ value = field_match[2].gsub(START_OR_END_QUOTE, '')
62
+ next unless value =~ ENDS_IN_CSS_UNIT
63
+ value_without_units = value.gsub(ENDS_IN_CSS_UNIT, '')
64
+ start = img_match.begin(0) + field_match.begin(2)
65
+ add_offense(
66
+ "The #{field_match[1]} attribute does not take units. Replace with \"#{value_without_units}\".",
67
+ node: @node,
68
+ markup: value,
69
+ line_number: @source[0...start].count("\n") + 1
70
+ )
71
+ end
72
+ end
73
+ end
74
+ end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
  module ThemeCheck
3
- # Recommends using {% liquid ... %} if 3 or more consecutive {% ... %} are found.
3
+ # Recommends using {% liquid ... %} if 4 or more consecutive {% ... %} are found.
4
4
  class LiquidTag < LiquidCheck
5
5
  severity :suggestion
6
6
  category :liquid
7
- doc "https://shopify.dev/docs/themes/liquid/reference/tags/theme-tags#liquid"
7
+ doc docs_url(__FILE__)
8
8
 
9
- def initialize(min_consecutive_statements: 10)
9
+ def initialize(min_consecutive_statements: 4)
10
10
  @first_statement = nil
11
11
  @consecutive_statements = 0
12
12
  @min_consecutive_statements = min_consecutive_statements
@@ -3,6 +3,7 @@ module ThemeCheck
3
3
  class MatchingSchemaTranslations < LiquidCheck
4
4
  severity :suggestion
5
5
  category :translation
6
+ doc docs_url(__FILE__)
6
7
 
7
8
  def on_schema(node)
8
9
  schema = JSON.parse(node.value.nodelist.join)
@@ -4,6 +4,7 @@ module ThemeCheck
4
4
  class MatchingTranslations < JsonCheck
5
5
  severity :suggestion
6
6
  category :translation
7
+ doc docs_url(__FILE__)
7
8
 
8
9
  def initialize
9
10
  @files = []
@@ -2,6 +2,7 @@
2
2
  module ThemeCheck
3
3
  class MissingEnableComment < LiquidCheck
4
4
  severity :error
5
+ doc docs_url(__FILE__)
5
6
 
6
7
  # Don't allow this check to be disabled with a comment,
7
8
  # as we need to be able to check for disabled checks.
@@ -3,11 +3,10 @@
3
3
  module ThemeCheck
4
4
  # Reports missing shopify required theme files
5
5
  # required templates: https://shopify.dev/tutorials/review-theme-store-requirements-files
6
-
7
6
  class MissingRequiredTemplateFiles < LiquidCheck
8
7
  severity :error
9
8
  category :liquid
10
- doc "https://shopify.dev/docs/themes/theme-templates"
9
+ doc docs_url(__FILE__)
11
10
 
12
11
  REQUIRED_LIQUID_FILES = %w(layout/theme)
13
12
  REQUIRED_TEMPLATE_FILES = %w(
@@ -4,6 +4,7 @@ module ThemeCheck
4
4
  class MissingTemplate < LiquidCheck
5
5
  severity :suggestion
6
6
  category :liquid
7
+ doc docs_url(__FILE__)
7
8
 
8
9
  def on_include(node)
9
10
  template = node.value.template_name_expr
@@ -4,6 +4,7 @@ module ThemeCheck
4
4
  class NestedSnippet < LiquidCheck
5
5
  severity :suggestion
6
6
  category :liquid
7
+ doc docs_url(__FILE__)
7
8
 
8
9
  class TemplateInfo < Struct.new(:includes)
9
10
  def with_deep_nested(templates, max, current_level = 0)
@@ -2,12 +2,14 @@
2
2
  module ThemeCheck
3
3
  # Reports errors when trying to use parser-blocking script tags
4
4
  class ParserBlockingJavaScript < LiquidCheck
5
+ include RegexHelpers
5
6
  severity :error
6
- category :liquid
7
+ categories :liquid, :performance
8
+ doc docs_url(__FILE__)
7
9
 
8
10
  PARSER_BLOCKING_SCRIPT_TAG = %r{
9
11
  <script # Find the start of a script tag
10
- (?=(?:[^>]|\n|\r)+?src=)+? # Make sure src= is in the script with a lookahead
12
+ (?=[^>]+?src=) # Make sure src= is in the script with a lookahead
11
13
  (?:(?!defer|async|type=["']module['"]).)*? # Find tags that don't have defer|async|type="module"
12
14
  >
13
15
  }xim
@@ -32,23 +34,14 @@ module ThemeCheck
32
34
  )
33
35
  end
34
36
 
35
- # The trickiness here is matching on scripts that are defined on
36
- # multiple lines (or repeat matches). This makes the line_number
37
- # calculation a bit weird. So instead, we traverse the string in
38
- # a very imperative way.
39
37
  def record_offenses_from_regex(regex: nil, message: nil)
40
- i = 0
41
- while (i = @source.index(regex, i))
42
- script = @source.match(regex, i)[0]
43
-
38
+ matches(@source, regex).each do |match|
44
39
  add_offense(
45
40
  message,
46
41
  node: @node,
47
- markup: script,
48
- line_number: @source[0...i].count("\n") + 1
42
+ markup: match[0],
43
+ line_number: @source[0...match.begin(0)].count("\n") + 1
49
44
  )
50
-
51
- i += script.size
52
45
  end
53
46
  end
54
47
  end
@@ -5,7 +5,7 @@ module ThemeCheck
5
5
  class RequiredDirectories < LiquidCheck
6
6
  severity :error
7
7
  category :liquid
8
- doc "https://shopify.dev/tutorials/develop-theme-files"
8
+ doc docs_url(__FILE__)
9
9
 
10
10
  REQUIRED_DIRECTORIES = %w(assets config layout locales sections snippets templates)
11
11
 
@@ -4,7 +4,7 @@ module ThemeCheck
4
4
  class RequiredLayoutThemeObject < LiquidCheck
5
5
  severity :error
6
6
  category :liquid
7
- doc "https://shopify.dev/docs/themes/theme-templates/theme-liquid"
7
+ doc docs_url(__FILE__)
8
8
 
9
9
  LAYOUT_FILENAME = "layout/theme"
10
10
 
@@ -4,6 +4,7 @@ module ThemeCheck
4
4
  class SpaceInsideBraces < LiquidCheck
5
5
  severity :style
6
6
  category :liquid
7
+ doc docs_url(__FILE__)
7
8
 
8
9
  def initialize
9
10
  @ignore = false
@@ -4,6 +4,7 @@ module ThemeCheck
4
4
  class SyntaxError < LiquidCheck
5
5
  severity :error
6
6
  category :liquid
7
+ doc docs_url(__FILE__)
7
8
 
8
9
  def on_document(node)
9
10
  node.template.warnings.each do |warning|
@@ -3,6 +3,7 @@ module ThemeCheck
3
3
  class TemplateLength < LiquidCheck
4
4
  severity :suggestion
5
5
  category :liquid
6
+ doc docs_url(__FILE__)
6
7
 
7
8
  def initialize(max_length: 200, exclude_schema: true)
8
9
  @max_length = max_length
@@ -1,8 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
  module ThemeCheck
3
+ module SystemTranslations
4
+ extend self
5
+
6
+ def translations
7
+ @translations ||= begin
8
+ # loaded as a Set because the include? lookup will be much faster.
9
+ YAML.load(File.read("#{__dir__}/../../../data/shopify_translation_keys.yml")).to_set
10
+ end
11
+ end
12
+
13
+ def include?(key)
14
+ translations.include?(key)
15
+ end
16
+ end
17
+
3
18
  class TranslationKeyExists < LiquidCheck
4
19
  severity :error
5
20
  category :translation
21
+ doc docs_url(__FILE__)
6
22
 
7
23
  def on_variable(node)
8
24
  return unless @theme.default_locale_json&.content&.is_a?(Hash)
@@ -11,7 +27,7 @@ module ThemeCheck
11
27
  return unless (key_node = node.children.first)
12
28
  return unless key_node.value.is_a?(String)
13
29
 
14
- unless key_exists?(key_node.value)
30
+ unless key_exists?(key_node.value) || SystemTranslations.include?(key_node.value)
15
31
  add_offense(
16
32
  "'#{key_node.value}' does not have a matching entry in '#{@theme.default_locale_json.relative_path}'",
17
33
  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
@@ -49,23 +55,28 @@ module ThemeCheck
49
55
  end
50
56
  end
51
57
 
52
- def initialize
58
+ def initialize(exclude_snippets: false)
59
+ @exclude_snippets = exclude_snippets
53
60
  @files = {}
54
61
  end
55
62
 
56
63
  def on_document(node)
64
+ return if ignore?(node)
57
65
  @files[node.template.name] = TemplateInfo.new
58
66
  end
59
67
 
60
68
  def on_assign(node)
69
+ return if ignore?(node)
61
70
  @files[node.template.name].all_assigns[node.value.to] = node
62
71
  end
63
72
 
64
73
  def on_capture(node)
74
+ return if ignore?(node)
65
75
  @files[node.template.name].all_captures[node.value.instance_variable_get('@to')] = node
66
76
  end
67
77
 
68
78
  def on_for(node)
79
+ return if ignore?(node)
69
80
  @files[node.template.name].all_forloops[node.value.variable_name] = node
70
81
  end
71
82
 
@@ -75,6 +86,7 @@ module ThemeCheck
75
86
  end
76
87
 
77
88
  def on_render(node)
89
+ return if ignore?(node)
78
90
  return unless node.value.template_name_expr.is_a?(String)
79
91
 
80
92
  snippet_name = "snippets/#{node.value.template_name_expr}"
@@ -85,6 +97,7 @@ module ThemeCheck
85
97
  end
86
98
 
87
99
  def on_variable_lookup(node)
100
+ return if ignore?(node)
88
101
  @files[node.template.name].add_variable_lookup(
89
102
  name: node.value.name,
90
103
  node: node,
@@ -114,15 +127,20 @@ module ThemeCheck
114
127
  end
115
128
  end
116
129
 
130
+ private
131
+
132
+ def ignore?(node)
133
+ @exclude_snippets && node.template.snippet?
134
+ end
135
+
117
136
  def each_template
118
137
  @files.each do |(name, info)|
119
138
  next if name.starts_with?('snippets/')
120
139
  yield [name, info]
121
140
  end
122
141
  end
123
- private :each_template
124
142
 
125
- 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)
126
144
  check_undefined(info, all_global_objects, render_node)
127
145
 
128
146
  info.each_snippet do |(snippet_name, node)|
@@ -131,16 +149,18 @@ module ThemeCheck
131
149
 
132
150
  snippet_variables = node.value.attributes.keys +
133
151
  Array[node.value.instance_variable_get("@alias_name")]
134
- 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
135
156
  end
136
157
  end
137
- private :check_object
138
158
 
139
159
  def check_undefined(info, all_global_objects, render_node)
140
160
  all_variables = info.all_variables
141
161
 
142
162
  info.each_variable_lookup(!!render_node) do |(key, node)|
143
- name, _line_number = key
163
+ name, line_number = key
144
164
  next if all_variables.include?(name)
145
165
  next if all_global_objects.include?(name)
146
166
 
@@ -150,10 +170,9 @@ module ThemeCheck
150
170
  if render_node
151
171
  add_offense("Missing argument `#{name}`", node: render_node)
152
172
  else
153
- add_offense("Undefined object `#{name}`", node: node)
173
+ add_offense("Undefined object `#{name}`", node: node, line_number: line_number)
154
174
  end
155
175
  end
156
176
  end
157
- private :check_undefined
158
177
  end
159
178
  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