theme-check 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (70) hide show
  1. checksums.yaml +7 -0
  2. data/.github/probots.yml +3 -0
  3. data/.github/workflows/theme-check.yml +28 -0
  4. data/.gitignore +13 -0
  5. data/.rubocop.yml +18 -0
  6. data/CODE_OF_CONDUCT.md +74 -0
  7. data/CONTRIBUTING.md +132 -0
  8. data/Gemfile +26 -0
  9. data/Guardfile +7 -0
  10. data/LICENSE.md +8 -0
  11. data/README.md +71 -0
  12. data/Rakefile +14 -0
  13. data/bin/liquid-server +4 -0
  14. data/config/default.yml +63 -0
  15. data/data/shopify_liquid/filters.yml +174 -0
  16. data/data/shopify_liquid/objects.yml +81 -0
  17. data/dev.yml +23 -0
  18. data/docs/preview.png +0 -0
  19. data/exe/theme-check +6 -0
  20. data/exe/theme-check-language-server +12 -0
  21. data/lib/theme_check.rb +25 -0
  22. data/lib/theme_check/analyzer.rb +43 -0
  23. data/lib/theme_check/check.rb +92 -0
  24. data/lib/theme_check/checks.rb +12 -0
  25. data/lib/theme_check/checks/convert_include_to_render.rb +13 -0
  26. data/lib/theme_check/checks/default_locale.rb +12 -0
  27. data/lib/theme_check/checks/liquid_tag.rb +48 -0
  28. data/lib/theme_check/checks/matching_schema_translations.rb +73 -0
  29. data/lib/theme_check/checks/matching_translations.rb +29 -0
  30. data/lib/theme_check/checks/missing_required_template_files.rb +29 -0
  31. data/lib/theme_check/checks/missing_template.rb +25 -0
  32. data/lib/theme_check/checks/nested_snippet.rb +46 -0
  33. data/lib/theme_check/checks/required_directories.rb +24 -0
  34. data/lib/theme_check/checks/required_layout_theme_object.rb +40 -0
  35. data/lib/theme_check/checks/space_inside_braces.rb +58 -0
  36. data/lib/theme_check/checks/syntax_error.rb +29 -0
  37. data/lib/theme_check/checks/template_length.rb +18 -0
  38. data/lib/theme_check/checks/translation_key_exists.rb +35 -0
  39. data/lib/theme_check/checks/undefined_object.rb +86 -0
  40. data/lib/theme_check/checks/unknown_filter.rb +25 -0
  41. data/lib/theme_check/checks/unused_assign.rb +54 -0
  42. data/lib/theme_check/checks/unused_snippet.rb +34 -0
  43. data/lib/theme_check/checks/valid_html_translation.rb +43 -0
  44. data/lib/theme_check/checks/valid_json.rb +14 -0
  45. data/lib/theme_check/checks/valid_schema.rb +13 -0
  46. data/lib/theme_check/checks_tracking.rb +8 -0
  47. data/lib/theme_check/cli.rb +78 -0
  48. data/lib/theme_check/config.rb +108 -0
  49. data/lib/theme_check/json_check.rb +11 -0
  50. data/lib/theme_check/json_file.rb +47 -0
  51. data/lib/theme_check/json_helpers.rb +9 -0
  52. data/lib/theme_check/language_server.rb +11 -0
  53. data/lib/theme_check/language_server/handler.rb +117 -0
  54. data/lib/theme_check/language_server/server.rb +140 -0
  55. data/lib/theme_check/liquid_check.rb +13 -0
  56. data/lib/theme_check/locale_diff.rb +69 -0
  57. data/lib/theme_check/node.rb +117 -0
  58. data/lib/theme_check/offense.rb +104 -0
  59. data/lib/theme_check/parsing_helpers.rb +17 -0
  60. data/lib/theme_check/printer.rb +74 -0
  61. data/lib/theme_check/shopify_liquid.rb +3 -0
  62. data/lib/theme_check/shopify_liquid/filter.rb +18 -0
  63. data/lib/theme_check/shopify_liquid/object.rb +16 -0
  64. data/lib/theme_check/tags.rb +146 -0
  65. data/lib/theme_check/template.rb +73 -0
  66. data/lib/theme_check/theme.rb +60 -0
  67. data/lib/theme_check/version.rb +4 -0
  68. data/lib/theme_check/visitor.rb +37 -0
  69. data/theme-check.gemspec +28 -0
  70. metadata +156 -0
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class Checks < Array
4
+ def call(method, *args)
5
+ each do |check|
6
+ if check.respond_to?(method) && !check.ignored?
7
+ check.send(method, *args)
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ # Recommends replacing `include` for `render`
4
+ class ConvertIncludeToRender < LiquidCheck
5
+ severity :suggestion
6
+ category :liquid
7
+ doc "https://shopify.dev/docs/themes/liquid/reference/tags/deprecated-tags#include"
8
+
9
+ def on_include(node)
10
+ add_offense("`include` is deprecated - convert it to `render`", node: node)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class DefaultLocale < JsonCheck
4
+ severity :suggestion
5
+ category :translation
6
+
7
+ def on_end
8
+ return if @theme.default_locale_json
9
+ add_offense("Default translation file not found (for example locales/en.default.json)")
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ # Recommends using {% liquid ... %} if 3 or more consecutive {% ... %} are found.
4
+ class LiquidTag < LiquidCheck
5
+ severity :suggestion
6
+ category :liquid
7
+ doc "https://shopify.dev/docs/themes/liquid/reference/tags/theme-tags#liquid"
8
+
9
+ def initialize(min_consecutive_statements: 10)
10
+ @first_statement = nil
11
+ @consecutive_statements = 0
12
+ @min_consecutive_statements = min_consecutive_statements
13
+ end
14
+
15
+ def on_tag(node)
16
+ if !node.inside_liquid_tag?
17
+ reset_consecutive_statements
18
+ # Ignore comments
19
+ elsif !node.comment?
20
+ increment_consecutive_statements(node)
21
+ end
22
+ end
23
+
24
+ def on_string(node)
25
+ # Only reset the counter on outputted strings, and ignore empty line-breaks
26
+ if node.parent.block? && !node.value.strip.empty?
27
+ reset_consecutive_statements
28
+ end
29
+ end
30
+
31
+ def after_document(_node)
32
+ reset_consecutive_statements
33
+ end
34
+
35
+ def increment_consecutive_statements(node)
36
+ @first_statement ||= node
37
+ @consecutive_statements += 1
38
+ end
39
+
40
+ def reset_consecutive_statements
41
+ if @consecutive_statements >= @min_consecutive_statements
42
+ add_offense("Use {% liquid ... %} to write multiple tags", node: @first_statement)
43
+ end
44
+ @first_statement = nil
45
+ @consecutive_statements = 0
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class MatchingSchemaTranslations < LiquidCheck
4
+ severity :suggestion
5
+ category :translation
6
+
7
+ def on_schema(node)
8
+ schema = JSON.parse(node.value.nodelist.join)
9
+
10
+ # Get all locales used in the schema
11
+ used_locales = Set.new([theme.default_locale])
12
+ visit_object(schema) do |_, locales|
13
+ used_locales += locales
14
+ end
15
+ used_locales = used_locales.to_a
16
+
17
+ # Check all used locales are defined in each localized keys
18
+ visit_object(schema) do |key, locales|
19
+ missing = used_locales - locales
20
+ if missing.any?
21
+ add_offense("#{key} missing translations for #{missing.join(', ')}", node: node)
22
+ end
23
+ end
24
+
25
+ check_locales(schema["locales"], node: node)
26
+
27
+ rescue JSON::ParserError
28
+ # Ignored, handled in ValidSchema.
29
+ end
30
+
31
+ private
32
+
33
+ def check_locales(locales, node:)
34
+ return unless locales.is_a?(Hash)
35
+
36
+ default_locale = locales[theme.default_locale]
37
+ if default_locale
38
+ locales.each_pair do |name, content|
39
+ diff = LocaleDiff.new(default_locale, content)
40
+ diff.add_as_offenses(self, key_prefix: ["locales", name], node: node)
41
+ end
42
+ else
43
+ add_offense("Missing default locale in key: locales", node: node)
44
+ end
45
+ end
46
+
47
+ def visit_object(object, top_path = [], &block)
48
+ return unless object.is_a?(Hash)
49
+ top_path += [object["id"]] if object["id"].is_a?(String)
50
+
51
+ object.each_pair do |key, value|
52
+ path = top_path + [key]
53
+
54
+ case value
55
+ when Array
56
+ value.each do |item|
57
+ visit_object(item, path, &block)
58
+ end
59
+
60
+ when Hash
61
+ # Localized key
62
+ if value[theme.default_locale].is_a?(String)
63
+ block.call(path.join("."), value.keys)
64
+ # Nested keys
65
+ else
66
+ visit_object(value, path, &block)
67
+ end
68
+
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ class MatchingTranslations < JsonCheck
5
+ severity :suggestion
6
+ category :translation
7
+
8
+ def initialize
9
+ @files = []
10
+ end
11
+
12
+ def on_file(file)
13
+ return unless file.name.starts_with?("locales/")
14
+ return unless file.content.is_a?(Hash)
15
+ return if file.name == @theme.default_locale_json&.name
16
+
17
+ @files << file
18
+ end
19
+
20
+ def on_end
21
+ return unless @theme.default_locale_json&.content
22
+
23
+ @files.each do |file|
24
+ diff = LocaleDiff.new(@theme.default_locale_json.content, file.content)
25
+ diff.add_as_offenses(self, template: file)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ # Reports missing shopify required theme files
5
+ # required templates: https://shopify.dev/tutorials/review-theme-store-requirements-files
6
+
7
+ class MissingRequiredTemplateFiles < LiquidCheck
8
+ severity :error
9
+ category :liquid
10
+ doc "https://shopify.dev/docs/themes/theme-templates"
11
+
12
+ LAYOUT_FILENAME = "layout/theme"
13
+ REQUIRED_TEMPLATES_FILES = %w(index product collection cart blog article page list-collections search 404
14
+ gift_card customers/account customers/activate_account customers/addresses
15
+ customers/login customers/order customers/register customers/reset_password password)
16
+ .map { |file| "templates/#{file}" }
17
+
18
+ def on_end
19
+ missing_files = (REQUIRED_TEMPLATES_FILES + [LAYOUT_FILENAME]) - theme.liquid.map(&:name)
20
+ missing_files.each { |file| add_missing_file_offense(file) }
21
+ end
22
+
23
+ private
24
+
25
+ def add_missing_file_offense(file)
26
+ add_offense("Theme is missing '#{file}.liquid' file")
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ # Reports missing include/render/section template
4
+ class MissingTemplate < LiquidCheck
5
+ severity :suggestion
6
+ category :liquid
7
+
8
+ def on_include(node)
9
+ template = node.value.template_name_expr
10
+ if template.is_a?(String)
11
+ unless theme["snippets/#{template}"]
12
+ add_offense("'snippets/#{template}.liquid' is not found", node: node)
13
+ end
14
+ end
15
+ end
16
+ alias_method :on_render, :on_include
17
+
18
+ def on_section(node)
19
+ template = node.value.section_name
20
+ unless theme["sections/#{template}"]
21
+ add_offense("'sections/#{template}.liquid' is not found", node: node)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ # Reports deeply nested {% include ... %} or {% render ... %}
4
+ class NestedSnippet < LiquidCheck
5
+ severity :suggestion
6
+ category :liquid
7
+
8
+ class TemplateInfo < Struct.new(:includes)
9
+ def with_deep_nested(templates, max, current_level = 0)
10
+ includes.each do |node|
11
+ if current_level >= max
12
+ yield node
13
+ else
14
+ template_name = "snippets/#{node.value.template_name_expr}"
15
+ templates[template_name]
16
+ &.with_deep_nested(templates, max, current_level + 1) { yield node }
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ def initialize(max_nesting_level: 2)
23
+ @max_nesting_level = max_nesting_level
24
+ @templates = {}
25
+ end
26
+
27
+ def on_document(node)
28
+ @templates[node.template.name] = TemplateInfo.new(Set.new)
29
+ end
30
+
31
+ def on_include(node)
32
+ if node.value.template_name_expr.is_a?(String)
33
+ @templates[node.template.name].includes << node
34
+ end
35
+ end
36
+ alias_method :on_render, :on_include
37
+
38
+ def on_end
39
+ @templates.each_pair do |_, info|
40
+ info.with_deep_nested(@templates, @max_nesting_level) do |node|
41
+ add_offense("Too many nested snippets", node: node)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ # Reports missing shopify required directories
4
+
5
+ class RequiredDirectories < LiquidCheck
6
+ severity :error
7
+ category :liquid
8
+ doc "https://shopify.dev/tutorials/develop-theme-files"
9
+
10
+ REQUIRED_DIRECTORIES = %w(assets config layout locales sections snippets templates)
11
+
12
+ def on_end
13
+ directories = theme.directories.map(&:to_s)
14
+ missing_directories = REQUIRED_DIRECTORIES - directories
15
+ missing_directories.each { |d| add_missing_directories_offense(d) }
16
+ end
17
+
18
+ private
19
+
20
+ def add_missing_directories_offense(directory)
21
+ add_offense("Theme is missing '#{directory}' directory")
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ # Reports missing content_for_header and content_for_layout in theme.liquid
4
+ class RequiredLayoutThemeObject < LiquidCheck
5
+ severity :error
6
+ category :liquid
7
+ doc "https://shopify.dev/docs/themes/theme-templates/theme-liquid"
8
+
9
+ LAYOUT_FILENAME = "layout/theme"
10
+
11
+ def initialize
12
+ @content_for_layout_found = false
13
+ @content_for_header_found = false
14
+ end
15
+
16
+ def on_document(node)
17
+ @layout_theme_node = node if node.template.name == LAYOUT_FILENAME
18
+ end
19
+
20
+ def on_variable(node)
21
+ return unless node.value.name.is_a?(Liquid::VariableLookup)
22
+
23
+ @content_for_header_found ||= node.value.name.name == "content_for_header"
24
+ @content_for_layout_found ||= node.value.name.name == "content_for_layout"
25
+ end
26
+
27
+ def after_document(node)
28
+ return unless node.template.name == LAYOUT_FILENAME
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
32
+ end
33
+
34
+ private
35
+
36
+ def add_missing_object_offense(name)
37
+ add_offense("#{LAYOUT_FILENAME} must include {{#{name}}}", node: @layout_theme_node)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ # Ensure {% ... %} & {{ ... }} have consistent spaces.
4
+ class SpaceInsideBraces < LiquidCheck
5
+ severity :style
6
+ category :liquid
7
+
8
+ def initialize
9
+ @ignore = false
10
+ end
11
+
12
+ def on_node(node)
13
+ return unless node.markup
14
+
15
+ outside_of_strings(node.markup) do |chunk|
16
+ chunk.scan(/([,:]) +/) do |_match|
17
+ add_offense("Too many spaces after '#{Regexp.last_match(1)}'", node: node, markup: Regexp.last_match(0))
18
+ end
19
+ chunk.scan(/([,:])\S/) do |_match|
20
+ add_offense("Space missing after '#{Regexp.last_match(1)}'", node: node, markup: Regexp.last_match(0))
21
+ end
22
+ end
23
+ end
24
+
25
+ def on_tag(node)
26
+ if node.inside_liquid_tag?
27
+ markup = if node.whitespace_trimmed?
28
+ "-%}"
29
+ else
30
+ "%}"
31
+ end
32
+ if node.markup[-1] != " " && node.markup[-1] != "\n"
33
+ add_offense("Space missing before '#{markup}'", node: node, markup: node.markup[-1] + markup)
34
+ elsif node.markup =~ /(\n?)( +)\z/m && Regexp.last_match(1) != "\n"
35
+ add_offense("Too many spaces before '#{markup}'", node: node, markup: Regexp.last_match(2) + markup)
36
+ end
37
+ end
38
+ @ignore = true
39
+ end
40
+
41
+ def after_tag(_node)
42
+ @ignore = false
43
+ end
44
+
45
+ def on_variable(node)
46
+ return if @ignore
47
+ if node.markup[0] != " "
48
+ add_offense("Space missing after '{{'", node: node)
49
+ elsif node.markup[-1] != " "
50
+ add_offense("Space missing before '}}'", node: node)
51
+ elsif node.markup[1] == " "
52
+ add_offense("Too many spaces after '{{'", node: node)
53
+ elsif node.markup[-2] == " "
54
+ add_offense("Too many spaces before '}}'", node: node)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ # Report Liquid syntax errors
4
+ class SyntaxError < LiquidCheck
5
+ severity :error
6
+ category :liquid
7
+
8
+ def on_document(node)
9
+ node.template.warnings.each do |warning|
10
+ add_exception_as_offense(warning, template: node.template)
11
+ end
12
+ end
13
+
14
+ def on_error(exception)
15
+ add_exception_as_offense(exception, template: theme[exception.template_name])
16
+ end
17
+
18
+ private
19
+
20
+ def add_exception_as_offense(exception, template:)
21
+ add_offense(
22
+ exception.to_s(false).sub(/ in ".*"$/, ''),
23
+ line_number: exception.line_number,
24
+ markup: exception.markup_context&.sub(/^in "(.*)"$/, '\1'),
25
+ template: template,
26
+ )
27
+ end
28
+ end
29
+ end