theme-check 0.1.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 (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