theme-check 1.7.2 → 1.9.2

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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/CHANGELOG.md +47 -0
  4. data/README.md +10 -0
  5. data/RELEASING.md +13 -0
  6. data/config/default.yml +5 -0
  7. data/data/shopify_liquid/deprecated_filters.yml +4 -0
  8. data/data/shopify_liquid/filters.yml +3 -1
  9. data/docs/checks/TEMPLATE.md.erb +24 -19
  10. data/docs/checks/schema_json_format.md +76 -0
  11. data/docs/language_server/code-action-command-palette.png +0 -0
  12. data/docs/language_server/code-action-flow.png +0 -0
  13. data/docs/language_server/code-action-keyboard.png +0 -0
  14. data/docs/language_server/code-action-light-bulb.png +0 -0
  15. data/docs/language_server/code-action-problem.png +0 -0
  16. data/docs/language_server/code-action-quickfix.png +0 -0
  17. data/docs/language_server/how_to_correct_code_with_code_actions_and_execute_command.md +197 -0
  18. data/exe/theme-check-language-server +0 -4
  19. data/lib/theme_check/checks/asset_size_app_block_css.rb +2 -3
  20. data/lib/theme_check/checks/asset_size_app_block_javascript.rb +2 -3
  21. data/lib/theme_check/checks/asset_url_filters.rb +2 -0
  22. data/lib/theme_check/checks/default_locale.rb +1 -1
  23. data/lib/theme_check/checks/deprecated_filter.rb +81 -4
  24. data/lib/theme_check/checks/deprecated_global_app_block_type.rb +2 -3
  25. data/lib/theme_check/checks/matching_schema_translations.rb +14 -9
  26. data/lib/theme_check/checks/matching_translations.rb +1 -0
  27. data/lib/theme_check/checks/missing_required_template_files.rb +3 -3
  28. data/lib/theme_check/checks/missing_template.rb +1 -1
  29. data/lib/theme_check/checks/pagination_size.rb +2 -3
  30. data/lib/theme_check/checks/remote_asset.rb +5 -0
  31. data/lib/theme_check/checks/required_directories.rb +1 -1
  32. data/lib/theme_check/checks/required_layout_theme_object.rb +9 -4
  33. data/lib/theme_check/checks/schema_json_format.rb +29 -0
  34. data/lib/theme_check/checks/space_inside_braces.rb +132 -87
  35. data/lib/theme_check/checks/translation_key_exists.rb +33 -13
  36. data/lib/theme_check/checks/unused_assign.rb +3 -2
  37. data/lib/theme_check/checks/unused_snippet.rb +1 -1
  38. data/lib/theme_check/checks/valid_html_translation.rb +1 -1
  39. data/lib/theme_check/checks/valid_schema.rb +2 -2
  40. data/lib/theme_check/corrector.rb +34 -23
  41. data/lib/theme_check/file_system_storage.rb +4 -3
  42. data/lib/theme_check/html_node.rb +122 -6
  43. data/lib/theme_check/html_visitor.rb +1 -32
  44. data/lib/theme_check/in_memory_storage.rb +9 -0
  45. data/lib/theme_check/json_helpers.rb +14 -0
  46. data/lib/theme_check/language_server/bridge.rb +19 -5
  47. data/lib/theme_check/language_server/client_capabilities.rb +27 -0
  48. data/lib/theme_check/language_server/code_action_engine.rb +32 -0
  49. data/lib/theme_check/language_server/code_action_provider.rb +42 -0
  50. data/lib/theme_check/language_server/code_action_providers/quickfix_code_action_provider.rb +83 -0
  51. data/lib/theme_check/language_server/code_action_providers/source_fix_all_code_action_provider.rb +40 -0
  52. data/lib/theme_check/language_server/configuration.rb +69 -0
  53. data/lib/theme_check/language_server/diagnostic.rb +124 -0
  54. data/lib/theme_check/language_server/diagnostics_engine.rb +15 -60
  55. data/lib/theme_check/language_server/diagnostics_manager.rb +136 -0
  56. data/lib/theme_check/language_server/document_change_corrector.rb +267 -0
  57. data/lib/theme_check/language_server/document_link_provider.rb +6 -6
  58. data/lib/theme_check/language_server/execute_command_engine.rb +19 -0
  59. data/lib/theme_check/language_server/execute_command_provider.rb +30 -0
  60. data/lib/theme_check/language_server/execute_command_providers/correction_execute_command_provider.rb +48 -0
  61. data/lib/theme_check/language_server/execute_command_providers/run_checks_execute_command_provider.rb +22 -0
  62. data/lib/theme_check/language_server/handler.rb +83 -29
  63. data/lib/theme_check/language_server/io_messenger.rb +11 -1
  64. data/lib/theme_check/language_server/protocol.rb +4 -0
  65. data/lib/theme_check/language_server/server.rb +29 -11
  66. data/lib/theme_check/language_server/uri_helper.rb +1 -0
  67. data/lib/theme_check/language_server/versioned_in_memory_storage.rb +69 -0
  68. data/lib/theme_check/language_server.rb +23 -5
  69. data/lib/theme_check/liquid_node.rb +255 -12
  70. data/lib/theme_check/locale_diff.rb +39 -8
  71. data/lib/theme_check/node.rb +16 -0
  72. data/lib/theme_check/offense.rb +27 -23
  73. data/lib/theme_check/position.rb +4 -4
  74. data/lib/theme_check/regex_helpers.rb +1 -1
  75. data/lib/theme_check/schema_helper.rb +70 -0
  76. data/lib/theme_check/storage.rb +4 -0
  77. data/lib/theme_check/tags.rb +0 -1
  78. data/lib/theme_check/theme.rb +1 -1
  79. data/lib/theme_check/theme_file.rb +8 -1
  80. data/lib/theme_check/theme_file_rewriter.rb +28 -6
  81. data/lib/theme_check/version.rb +1 -1
  82. data/lib/theme_check.rb +11 -2
  83. metadata +26 -3
  84. data/lib/theme_check/language_server/diagnostics_tracker.rb +0 -66
@@ -5,18 +5,95 @@ module ThemeCheck
5
5
  category :liquid
6
6
  severity :suggestion
7
7
 
8
+ # The image_url filter does not accept width or height values
9
+ # greater than this numbr.
10
+ MAX_SIZE = 5760
11
+
8
12
  def on_variable(node)
9
13
  used_filters = node.value.filters.map { |name, *_rest| name }
10
14
  used_filters.each do |filter|
11
15
  alternatives = ShopifyLiquid::DeprecatedFilter.alternatives(filter)
12
16
  next unless alternatives
13
17
 
14
- alternatives = alternatives.map { |alt| "`#{alt}`" }
15
- add_offense(
16
- "Deprecated filter `#{filter}`, consider using an alternative: #{alternatives.join(', ')}",
17
- node: node,
18
+ case filter
19
+ when 'img_url'
20
+ add_img_url_offense(node)
21
+ else
22
+ add_default_offense(node, filter, alternatives)
23
+ end
24
+ end
25
+ end
26
+
27
+ def add_default_offense(node, filter, alternatives)
28
+ alternatives = alternatives.map { |alt| "`#{alt}`" }
29
+ add_offense(
30
+ "Deprecated filter `#{filter}`, consider using an alternative: #{alternatives.join(', ')}",
31
+ node: node,
32
+ )
33
+ end
34
+
35
+ def add_img_url_offense(node)
36
+ img_url_filter = node.value.filters.find { |filter| filter[0] == "img_url" }
37
+ _name, img_url_filter_size, img_url_filter_props = img_url_filter
38
+ size_spec = img_url_filter_size&.dig(0)
39
+ scale = img_url_filter_props&.delete("scale")
40
+
41
+ # Can't correct those.
42
+ return add_default_offense(node, 'img_url', ['image_url']) unless
43
+ (size_spec.nil? || size_spec.is_a?(String)) &&
44
+ (scale.nil? || scale.is_a?(Numeric)) &&
45
+ size_spec != 'small'
46
+
47
+ node_source = node.markup
48
+ node_start_index = node.start_index
49
+ match = node_source.match(/img_url[^|]*/)
50
+ img_url_character_range =
51
+ (node_start_index + match.begin(0))...(node_start_index + match.end(0))
52
+
53
+ scale = (scale || 1).to_i
54
+ width, height = (size_spec&.split('x') || [100, 100])
55
+ .map { |v| v.to_i * scale }
56
+
57
+ image_url_filter_params = [
58
+ width && width > 0 ? "width: #{[width, MAX_SIZE].min}" : nil,
59
+ height && height > 0 ? "height: #{[height, MAX_SIZE].min}" : nil,
60
+ ]
61
+ image_url_filter_params += (img_url_filter_props || {})
62
+ .map do |k, v|
63
+ case v
64
+ when Liquid::VariableLookup
65
+ "#{k}: #{v.name}"
66
+ when String
67
+ "#{k}: '#{v}'"
68
+ else
69
+ "#{k}: #{v}"
70
+ end
71
+ end
72
+ image_url_filter_params = image_url_filter_params
73
+ .reject(&:nil?)
74
+ .join(", ")
75
+
76
+ trailing_whitespace = match[0].match(/\s*\Z/)[0]
77
+
78
+ image_url_filter = "image_url"
79
+ image_url_filter += ": " + image_url_filter_params unless image_url_filter_params.empty?
80
+ image_url_filter += trailing_whitespace
81
+
82
+ add_offense(
83
+ "Deprecated filter `img_url`, consider using `image_url`",
84
+ node: node,
85
+ markup: match[0]
86
+ ) do |corrector|
87
+ corrector.replace(
88
+ node,
89
+ image_url_filter,
90
+ img_url_character_range,
18
91
  )
19
92
  end
93
+
94
+ # If anything goes wrong, fail gracefully by returning the default offense.
95
+ rescue
96
+ add_default_offense(node, 'img_url', ['image_url'])
20
97
  end
21
98
  end
22
99
  end
@@ -9,7 +9,8 @@ module ThemeCheck
9
9
  VALID_GLOBAL_APP_BLOCK_TYPE = "@app"
10
10
 
11
11
  def on_schema(node)
12
- schema = JSON.parse(node.value.nodelist.join)
12
+ schema = node.inner_json
13
+ return if schema.nil?
13
14
 
14
15
  if block_types_from(schema).include?(INVALID_GLOBAL_APP_BLOCK_TYPE)
15
16
  add_offense(
@@ -17,8 +18,6 @@ module ThemeCheck
17
18
  node: node
18
19
  )
19
20
  end
20
- rescue JSON::ParserError
21
- # Ignored, handled in ValidSchema.
22
21
  end
23
22
 
24
23
  def on_case(node)
@@ -6,8 +6,8 @@ module ThemeCheck
6
6
  doc docs_url(__FILE__)
7
7
 
8
8
  def on_schema(node)
9
- schema = JSON.parse(node.value.nodelist.join)
10
-
9
+ schema = node.inner_json
10
+ return if schema.nil?
11
11
  # Get all locales used in the schema
12
12
  used_locales = Set.new([theme.default_locale])
13
13
  visit_object(schema) do |_, locales|
@@ -19,26 +19,31 @@ module ThemeCheck
19
19
  visit_object(schema) do |key, locales|
20
20
  missing = used_locales - locales
21
21
  if missing.any?
22
- add_offense("#{key} missing translations for #{missing.join(', ')}", node: node)
22
+ add_offense("#{key} missing translations for #{missing.join(', ')}", node: node) do |corrector|
23
+ key = key.split(".")
24
+ missing.each do |language|
25
+ SchemaHelper.schema_corrector(schema, key + [language], "TODO")
26
+ end
27
+ corrector.replace_inner_json(node, schema)
28
+ end
23
29
  end
24
30
  end
25
31
 
26
- check_locales(schema["locales"], node: node)
27
-
28
- rescue JSON::ParserError
29
- # Ignored, handled in ValidSchema.
32
+ check_locales(schema, node: node)
30
33
  end
31
34
 
32
35
  private
33
36
 
34
- def check_locales(locales, node:)
37
+ def check_locales(schema, node:)
38
+ locales = schema["locales"]
35
39
  return unless locales.is_a?(Hash)
36
40
 
37
41
  default_locale = locales[theme.default_locale]
42
+
38
43
  if default_locale
39
44
  locales.each_pair do |name, content|
40
45
  diff = LocaleDiff.new(default_locale, content)
41
- diff.add_as_offenses(self, key_prefix: ["locales", name], node: node)
46
+ diff.add_as_offenses(self, key_prefix: ["locales", name], node: node, schema: schema)
42
47
  end
43
48
  else
44
49
  add_offense("Missing default locale in key: locales", node: node)
@@ -13,6 +13,7 @@ module ThemeCheck
13
13
  def on_file(file)
14
14
  return unless file.name.start_with?("locales/")
15
15
  return unless file.content.is_a?(Hash)
16
+ return if file.name =~ /\.schema$/
16
17
  return if file.name == @theme.default_locale_json&.name
17
18
 
18
19
  @files << file
@@ -25,15 +25,15 @@ module ThemeCheck
25
25
  def on_end
26
26
  (REQUIRED_LIQUID_FILES - theme.liquid.map(&:name)).each do |file|
27
27
  add_offense("'#{file}.liquid' is missing") do |corrector|
28
- corrector.create(@theme, "#{file}.liquid", "")
28
+ corrector.create_file(@theme.storage, "#{file}.liquid", "")
29
29
  end
30
30
  end
31
31
  (REQUIRED_TEMPLATE_FILES - (theme.liquid + theme.json).map(&:name)).each do |file|
32
32
  add_offense("'#{file}.liquid' or '#{file}.json' is missing") do |corrector|
33
33
  if REQUIRED_LIQUID_TEMPLATE_FILES.include?(file)
34
- corrector.create(@theme, "#{file}.liquid", "")
34
+ corrector.create_file(@theme.storage, "#{file}.liquid", "")
35
35
  else
36
- corrector.create(@theme, "#{file}.json", "")
36
+ corrector.create_file(@theme.storage, "#{file}.json", "")
37
37
  end
38
38
  end
39
39
  end
@@ -35,7 +35,7 @@ module ThemeCheck
35
35
  path = "#{name}.liquid"
36
36
  unless ignore?(path) || theme[name]
37
37
  add_offense("'#{path}' is not found", node: node) do |corrector|
38
- corrector.create(@theme, "#{name}.liquid", "")
38
+ corrector.create_file(@theme.storage, "#{name}.liquid", "")
39
39
  end
40
40
  end
41
41
  end
@@ -27,13 +27,12 @@ module ThemeCheck
27
27
  end
28
28
 
29
29
  def on_schema(node)
30
- schema = JSON.parse(node.value.nodelist.join)
30
+ schema = node.inner_json
31
+ return if schema.nil?
31
32
 
32
33
  if (settings = schema["settings"])
33
34
  @schema_settings = settings
34
35
  end
35
- rescue JSON::ParserError
36
- # Ignored, handled in ValidSchema.
37
36
  end
38
37
 
39
38
  ##
@@ -22,6 +22,7 @@ module ThemeCheck
22
22
  return if resource_url =~ ABSOLUTE_PATH
23
23
  return if resource_url =~ RELATIVE_PATH
24
24
  return if url_hosted_by_shopify?(resource_url)
25
+ return if url_is_setting_variable?(resource_url)
25
26
 
26
27
  # Ignore non-stylesheet link tags
27
28
  rel = node.attributes["rel"]
@@ -39,5 +40,9 @@ module ThemeCheck
39
40
  url.start_with?(Liquid::VariableStart) &&
40
41
  AssetUrlFilters::ASSET_URL_FILTERS.any? { |filter| url.include?(filter) }
41
42
  end
43
+
44
+ def url_is_setting_variable?(url)
45
+ url.start_with?(Liquid::VariableStart) && url =~ /settings\./
46
+ end
42
47
  end
43
48
  end
@@ -19,7 +19,7 @@ module ThemeCheck
19
19
 
20
20
  def add_missing_directories_offense(directory)
21
21
  add_offense("Theme is missing '#{directory}' directory") do |corrector|
22
- corrector.mkdir(@theme, directory)
22
+ corrector.mkdir(@theme.storage, directory)
23
23
  end
24
24
  end
25
25
  end
@@ -27,14 +27,19 @@ module ThemeCheck
27
27
  def after_document(node)
28
28
  return unless node.theme_file.name == LAYOUT_FILENAME
29
29
 
30
- add_missing_object_offense("content_for_layout") unless @content_for_layout_found
31
- add_missing_object_offense("content_for_header") unless @content_for_header_found
30
+ add_missing_object_offense("content_for_layout", "</body>") unless @content_for_layout_found
31
+ add_missing_object_offense("content_for_header", "</head>") unless @content_for_header_found
32
32
  end
33
33
 
34
34
  private
35
35
 
36
- def add_missing_object_offense(name)
37
- add_offense("#{LAYOUT_FILENAME} must include {{#{name}}}", node: @layout_theme_node)
36
+ def add_missing_object_offense(name, tag)
37
+ add_offense("#{LAYOUT_FILENAME} must include {{#{name}}}", node: @layout_theme_node) do
38
+ if @layout_theme_node.source.index(tag)
39
+ @layout_theme_node.source.insert(@layout_theme_node.source.index(tag), " {{ #{name} }}\n ")
40
+ @layout_theme_node.markup = @layout_theme_node.source
41
+ end
42
+ end
38
43
  end
39
44
  end
40
45
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class SchemaJsonFormat < LiquidCheck
4
+ severity :style
5
+ category :liquid
6
+ doc docs_url(__FILE__)
7
+
8
+ def initialize(start_level: 0, indent: ' ')
9
+ @pretty_json_opts = {
10
+ indent: indent,
11
+ start_level: start_level,
12
+ }
13
+ end
14
+
15
+ def on_schema(node)
16
+ schema = node.inner_json
17
+ return if schema.nil?
18
+ pretty_schema = pretty_json(schema, **@pretty_json_opts)
19
+ if pretty_schema != node.inner_markup
20
+ add_offense(
21
+ "JSON formatting could be improved",
22
+ node: node,
23
+ ) do |corrector|
24
+ corrector.replace_inner_json(node, schema, **@pretty_json_opts)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -6,115 +6,160 @@ module ThemeCheck
6
6
  category :liquid
7
7
  doc docs_url(__FILE__)
8
8
 
9
- def initialize
10
- @ignore = false
11
- end
12
-
13
9
  def on_node(node)
14
10
  return unless node.markup
15
- return if :assign == node.type_name
11
+ return if node.literal?
12
+ return if node.assigned_or_echoed_variable?
16
13
 
17
14
  outside_of_strings(node.markup) do |chunk, chunk_start|
18
- chunk.scan(/([,:|]|==|<>|<=|>=|<|>|!=)( +)/) do |_match|
19
- add_offense(
20
- "Too many spaces after '#{Regexp.last_match(1)}'",
21
- node: node,
22
- markup: Regexp.last_match(2),
23
- node_markup_offset: chunk_start + Regexp.last_match.begin(2)
24
- )
15
+ chunk.scan(/(?<token>[,:|]|==|<>|<=|>=|<|>|!=)(?<offense> +)/) do |_match|
16
+ add_too_many_spaces_after_offense(Regexp.last_match, node, chunk_start)
25
17
  end
26
- chunk.scan(/([,:|]|==|<>|<=|>=|<\b|>\b|!=)(\S|\z)/) do |_match|
27
- add_offense(
28
- "Space missing after '#{Regexp.last_match(1)}'",
29
- node: node,
30
- markup: Regexp.last_match(1),
31
- node_markup_offset: chunk_start + Regexp.last_match.begin(0),
32
- )
18
+ chunk.scan(/(?<offense>(?<token>[,:|]|==|<>|<=|>=|<\b|>\b|!=)(\S|\z))/) do |_match|
19
+ add_space_missing_after_offense(Regexp.last_match, node, chunk_start)
33
20
  end
34
- chunk.scan(/( +)(\||==|<>|<=|>=|<|>|!=)+/) do |_match|
35
- add_offense(
36
- "Too many spaces before '#{Regexp.last_match(2)}'",
37
- node: node,
38
- markup: Regexp.last_match(1),
39
- node_markup_offset: chunk_start + Regexp.last_match.begin(1)
40
- )
21
+ chunk.scan(/(?<offense>\s{2,})(?<token>\||==|<>|<=|>=|<|>|!=)+/) do |_match|
22
+ unless Regexp.last_match(:offense).include?("\n")
23
+ add_too_many_spaces_before_offense(Regexp.last_match, node, chunk_start)
24
+ end
41
25
  end
42
- chunk.scan(/(\A|\S)(?<match>\||==|<>|<=|>=|<|\b>|!=)/) do |_match|
43
- add_offense(
44
- "Space missing before '#{Regexp.last_match(1)}'",
45
- node: node,
46
- markup: Regexp.last_match(:match),
47
- node_markup_offset: chunk_start + Regexp.last_match.begin(:match)
48
- )
26
+ chunk.scan(/(\A|\S)(?<offense>(?<token>\||==|<>|<=|>=|<|\b>|!=))/) do |_match|
27
+ add_space_missing_before_offense(Regexp.last_match, node, chunk_start)
49
28
  end
50
29
  end
51
30
  end
52
31
 
32
+ BlockMarkup = Struct.new(:markup, :node_markup_offset)
33
+
53
34
  def on_tag(node)
54
- unless node.inside_liquid_tag?
55
- if node.markup[-1] != " " && node.markup[-1] != "\n"
56
- add_offense(
57
- "Space missing before '#{node.end_token}'",
58
- node: node,
59
- markup: node.markup[-1],
60
- node_markup_offset: node.markup.size - 1,
61
- )
62
- elsif node.markup =~ /(\n?)( +)\z/m && Regexp.last_match(1) != "\n"
63
- add_offense(
64
- "Too many spaces before '#{node.end_token}'",
65
- node: node,
66
- markup: Regexp.last_match(2),
67
- node_markup_offset: node.markup.size - Regexp.last_match(2).size
68
- )
35
+ return if node.inside_liquid_tag?
36
+
37
+ # Both the start and end tags
38
+ blocks = [
39
+ BlockMarkup.new(node.block_start_markup, node.block_start_start_index - node.start_index),
40
+ BlockMarkup.new(node.block_end_markup, node.block_end_start_index - node.start_index),
41
+ ]
42
+
43
+ blocks.each do |block|
44
+ # Looking at spaces after the start token
45
+ if block.markup =~ /^(?<token>{%-?)(?<offense>[^ \n\t-])/
46
+ add_space_missing_after_offense(Regexp.last_match, node, block.node_markup_offset)
47
+ end
48
+
49
+ if block.markup =~ /^(?<token>{%-?)(?<offense> {2,})\S/
50
+ add_too_many_spaces_after_offense(Regexp.last_match, node, block.node_markup_offset)
51
+ end
52
+
53
+ # Looking at spaces before the end token
54
+ if block.markup =~ /(?<offense>[^ \n\t-])(?<token>-?%})$/
55
+ add_space_missing_before_offense(Regexp.last_match, node, block.node_markup_offset)
69
56
  end
57
+
58
+ if block.markup =~ /\S(?<offense> {2,})(?<token>-?%})$/
59
+ add_too_many_spaces_before_offense(Regexp.last_match, node, block.node_markup_offset)
60
+ end
61
+
62
+ next
70
63
  end
71
- @ignore = true
72
64
  end
73
65
 
74
- def after_tag(_node)
75
- @ignore = false
66
+ def on_variable(node)
67
+ return if node.markup.empty?
68
+ return if node.assigned_or_echoed_variable?
69
+
70
+ block_start_offset = node.block_start_start_index - node.start_index
71
+
72
+ # Looking at spaces after the start token
73
+ if node.block_start_markup =~ /^(?<token>{{-?)(?<offense>[^ \n\t-])/
74
+ add_space_missing_after_offense(Regexp.last_match, node, block_start_offset)
75
+ end
76
+
77
+ if node.block_start_markup =~ /^(?<token>{{-?)(?<offense> {2,})\S/
78
+ add_too_many_spaces_after_offense(Regexp.last_match, node, block_start_offset)
79
+ end
80
+
81
+ # Looking at spaces before the end token
82
+ if node.block_start_markup =~ /(?<offense>[^ \n\t-])(?<token>-?}})$/
83
+ add_space_missing_before_offense(Regexp.last_match, node, block_start_offset)
84
+ end
85
+
86
+ if node.block_start_markup =~ /\S(?<offense> {2,})(?<token>-?}})$/
87
+ add_too_many_spaces_before_offense(Regexp.last_match, node, block_start_offset)
88
+ end
76
89
  end
77
90
 
78
- def on_variable(node)
79
- return if @ignore || node.markup.empty?
80
- if node.markup[0] != " "
81
- add_offense(
82
- "Space missing after '#{node.start_token}'",
83
- node: node,
84
- markup: node.markup[0]
85
- ) do |corrector|
86
- corrector.insert_before(node, " ")
87
- end
91
+ def add_space_missing_after_offense(match, node, source_offset)
92
+ add_offense_for_match(
93
+ "Space missing after '#{match[:token]}'",
94
+ match,
95
+ node,
96
+ source_offset
97
+ ) do |corrector|
98
+ corrector.insert_after(
99
+ node,
100
+ ' ',
101
+ (node.start_index + source_offset + match.begin(:token))...
102
+ (node.start_index + source_offset + match.end(:token))
103
+ )
88
104
  end
89
- if node.markup[-1] != " " && node.markup[-1] != "\n"
90
- add_offense(
91
- "Space missing before '#{node.end_token}'",
92
- node: node,
93
- markup: node.markup[-1],
94
- node_markup_offset: node.markup.size - 1,
95
- ) do |corrector|
96
- corrector.insert_after(node, " ")
97
- end
105
+ end
106
+
107
+ def add_too_many_spaces_after_offense(match, node, source_offset)
108
+ add_offense_for_match(
109
+ "Too many spaces after '#{match[:token]}'",
110
+ match,
111
+ node,
112
+ source_offset
113
+ ) do |corrector|
114
+ corrector.replace(
115
+ node,
116
+ ' ',
117
+ (node.start_index + source_offset + match.begin(:offense))...
118
+ (node.start_index + source_offset + match.end(:offense))
119
+ )
98
120
  end
99
- if node.markup =~ /\A( +)/m
100
- add_offense(
101
- "Too many spaces after '#{node.start_token}'",
102
- node: node,
103
- markup: Regexp.last_match(1),
104
- ) do |corrector|
105
- corrector.replace(node, " #{node.markup.lstrip}")
106
- end
121
+ end
122
+
123
+ def add_space_missing_before_offense(match, node, source_offset)
124
+ add_offense_for_match(
125
+ "Space missing before '#{match[:token]}'",
126
+ match,
127
+ node,
128
+ source_offset
129
+ ) do |corrector|
130
+ corrector.insert_before(
131
+ node,
132
+ ' ',
133
+ (node.start_index + source_offset + match.begin(:token))...
134
+ (node.start_index + source_offset + match.end(:token))
135
+ )
107
136
  end
108
- if node.markup =~ /(\n?)( +)\z/m && Regexp.last_match(1) != "\n"
109
- add_offense(
110
- "Too many spaces before '#{node.end_token}'",
111
- node: node,
112
- markup: Regexp.last_match(2),
113
- node_markup_offset: node.markup.size - Regexp.last_match(2).size
114
- ) do |corrector|
115
- corrector.replace(node, "#{node.markup.rstrip} ")
116
- end
137
+ end
138
+
139
+ def add_too_many_spaces_before_offense(match, node, source_offset)
140
+ add_offense_for_match(
141
+ "Too many spaces before '#{match[:token]}'",
142
+ match,
143
+ node,
144
+ source_offset
145
+ ) do |corrector|
146
+ corrector.replace(
147
+ node,
148
+ ' ',
149
+ (node.start_index + source_offset + match.begin(:offense))...
150
+ (node.start_index + source_offset + match.end(:offense))
151
+ )
117
152
  end
118
153
  end
154
+
155
+ def add_offense_for_match(message, match, node, source_offset, &block)
156
+ add_offense(
157
+ message,
158
+ node: node,
159
+ markup: match[:offense],
160
+ node_markup_offset: source_offset + match.begin(:offense),
161
+ &block
162
+ )
163
+ end
119
164
  end
120
165
  end
@@ -5,28 +5,48 @@ module ThemeCheck
5
5
  category :translation
6
6
  doc docs_url(__FILE__)
7
7
 
8
+ def initialize
9
+ @schema_locales = {}
10
+ @nodes = {}
11
+ end
12
+
13
+ def on_document(node)
14
+ @nodes[node.theme_file.name] = []
15
+ end
16
+
8
17
  def on_variable(node)
9
18
  return unless @theme.default_locale_json&.content&.is_a?(Hash)
10
-
11
19
  return unless node.value.filters.any? { |name, _| name == "t" || name == "translate" }
12
- return unless (key_node = node.children.first)
13
- return unless key_node.value.is_a?(String)
14
-
15
- unless key_exists?(key_node.value) || ShopifyLiquid::SystemTranslations.include?(key_node.value)
16
- add_offense(
17
- "'#{key_node.value}' does not have a matching entry in '#{@theme.default_locale_json.relative_path}'",
18
- node: node,
19
- markup: key_node.value,
20
- ) do |corrector|
21
- corrector.add_default_translation_key(@theme.default_locale_json, key_node.value.split("."), "TODO")
20
+
21
+ @nodes[node.theme_file.name] << node
22
+ end
23
+
24
+ def on_schema(node)
25
+ if (schema_locales = node.inner_json&.dig("locales", @theme.default_locale))
26
+ @schema_locales = schema_locales
27
+ end
28
+ end
29
+
30
+ def on_end
31
+ @nodes.each_pair do |_file_name, file_nodes|
32
+ file_nodes.each do |node|
33
+ next unless (key_node = node.children.first)
34
+ next unless key_node.value.is_a?(String)
35
+ next if key_exists?(key_node.value, @theme.default_locale_json.content) || key_exists?(key_node.value, @schema_locales) || ShopifyLiquid::SystemTranslations.include?(key_node.value)
36
+ add_offense(
37
+ @schema_locales.empty? ? "'#{key_node.value}' does not have a matching entry in '#{@theme.default_locale_json.relative_path}'" : "'#{key_node.value}' does not have a matching entry in '#{@theme.default_locale_json.relative_path}' or '#{node.theme_file.relative_path}'",
38
+ node: node,
39
+ markup: key_node.value
40
+ ) do |corrector|
41
+ corrector.add_translation(@theme.default_locale_json, key_node.value.split("."), "TODO")
42
+ end
22
43
  end
23
44
  end
24
45
  end
25
46
 
26
47
  private
27
48
 
28
- def key_exists?(key)
29
- pointer = @theme.default_locale_json.content
49
+ def key_exists?(key, pointer)
30
50
  key.split(".").each do |token|
31
51
  return false unless pointer.key?(token)
32
52
  pointer = pointer[token]
@@ -46,8 +46,9 @@ module ThemeCheck
46
46
  @templates.each_pair do |_, info|
47
47
  used = info.collect_used_assigns(@templates)
48
48
  info.assign_nodes.each_pair do |name, node|
49
- unless used.include?(name)
50
- add_offense("`#{name}` is never used", node: node)
49
+ next if used.include?(name)
50
+ add_offense("`#{name}` is never used", node: node) do |corrector|
51
+ corrector.remove(node)
51
52
  end
52
53
  end
53
54
  end
@@ -25,7 +25,7 @@ module ThemeCheck
25
25
  def on_end
26
26
  missing_snippets.each do |theme_file|
27
27
  add_offense("This snippet is not used", theme_file: theme_file) do |corrector|
28
- corrector.remove(@theme, theme_file.relative_path.to_s)
28
+ corrector.remove_file(@theme.storage, theme_file.relative_path.to_s)
29
29
  end
30
30
  end
31
31
  end
@@ -19,7 +19,7 @@ module ThemeCheck
19
19
 
20
20
  def html_key?(keys)
21
21
  pluralized_key = keys[-2] if keys.length > 1
22
- keys[-1].end_with?('_html') || pluralized_key.end_with?('_html')
22
+ keys[-1].end_with?('_html') || pluralized_key&.end_with?('_html')
23
23
  end
24
24
 
25
25
  def parse_and_add_offense(key, value)