theme-check 0.3.1 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
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